Compare commits

..

2 Commits

Author SHA1 Message Date
Oleksandr Bezdieniezhnykh 78dea8ebab chore: update configuration and Docker setup for JWT and test results
ci/woodpecker/push/build-arm Pipeline was successful
Enhanced the .gitignore to exclude test results and updated the Dockerfile to include a new entrypoint script for improved container initialization. Refactored JWT configuration to support additional parameters for automatic refresh intervals, ensuring better control over token management. Updated the ConfigurationResolver to enforce required environment variables without hardcoded fallbacks, enhancing security and flexibility.
2026-05-15 03:23:23 +03:00
Oleksandr Bezdieniezhnykh 7025f4d075 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.
2026-05-14 19:48:25 +03:00
86 changed files with 9975 additions and 20 deletions
+3
View File
@@ -26,3 +26,6 @@ Thumbs.db
appsettings.*.json
!appsettings.json
!appsettings.Development.json
## Test results (produced by scripts/run-tests.sh and run-performance-tests.sh)
test-results/
+85 -8
View File
@@ -1,31 +1,108 @@
using System.Text;
using Azaion.Flights.Infrastructure;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Tokens;
namespace Azaion.Flights.Auth;
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 const string JwtJwksAutoRefreshSecondsEnvVar = "JWT_JWKS_AUTO_REFRESH_INTERVAL_SECONDS";
public const string JwtJwksAutoRefreshSecondsConfigKey = "Jwt:JwksAutoRefreshIntervalSeconds";
public const string JwtJwksRefreshSecondsEnvVar = "JWT_JWKS_REFRESH_INTERVAL_SECONDS";
public const string JwtJwksRefreshSecondsConfigKey = "Jwt:JwksRefreshIntervalSeconds";
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");
// Optional interval overrides. Production leaves both unset and inherits
// the library defaults (AutomaticRefreshInterval = 12h, RefreshInterval =
// 5min). Tests set them to small values so JWKS rotation can be observed
// inside the CI wall-clock budget.
var autoRefreshSeconds = ConfigurationResolver.ResolveOptionalPositiveIntOrThrow(
configuration, JwtJwksAutoRefreshSecondsEnvVar, JwtJwksAutoRefreshSecondsConfigKey,
"JWKS automatic refresh interval (seconds)");
var refreshSeconds = ConfigurationResolver.ResolveOptionalPositiveIntOrThrow(
configuration, JwtJwksRefreshSecondsEnvVar, JwtJwksRefreshSecondsConfigKey,
"JWKS refresh interval (seconds)");
// 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 });
if (autoRefreshSeconds is int autoSec)
jwksConfigManager.AutomaticRefreshInterval = TimeSpan.FromSeconds(autoSec);
if (refreshSeconds is int refreshSec)
jwksConfigManager.RefreshInterval = TimeSpan.FromSeconds(refreshSec);
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = issuer,
ValidateAudience = true,
ValidAudience = audience,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
// 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);
}
};
});
services.AddAuthorizationBuilder()
.AddPolicy("FL", p => p.RequireClaim("permissions", "FL"))
.AddPolicy("FL", p => p.RequireClaim("permissions", "FL"))
.AddPolicy("GPS", p => p.RequireClaim("permissions", "GPS"));
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);
}
}
}
+3 -1
View File
@@ -10,5 +10,7 @@ ARG CI_COMMIT_SHA=unknown
ENV AZAION_REVISION=$CI_COMMIT_SHA
WORKDIR /app
COPY --from=build /app .
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
EXPOSE 8080
ENTRYPOINT ["dotnet", "Azaion.Flights.dll"]
ENTRYPOINT ["/docker-entrypoint.sh", "dotnet", "Azaion.Flights.dll"]
+56
View File
@@ -0,0 +1,56 @@
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;
}
// Optional positive-integer override (e.g. JWKS refresh interval tuning for tests).
// Returns null when unset/whitespace so callers keep library defaults.
// Throws when set-but-unparseable or non-positive, so a typo can never silently
// weaken behavior.
public static int? ResolveOptionalPositiveIntOrThrow(
IConfiguration configuration,
string envVar,
string configKey,
string humanLabel)
{
ArgumentNullException.ThrowIfNull(configuration);
var raw = Environment.GetEnvironmentVariable(envVar);
if (string.IsNullOrWhiteSpace(raw))
raw = configuration[configKey];
if (string.IsNullOrWhiteSpace(raw))
return null;
if (!int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var parsed) || parsed <= 0)
{
throw new InvalidOperationException(
$"{humanLabel} is set to '{raw}' which is not a positive integer. " +
$"Set {envVar} (or {configKey}) to a positive integer count of seconds, or unset it to use the library default.");
}
return parsed;
}
}
@@ -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 Azaion.Flights.Auth;
using Azaion.Flights.Database;
using Azaion.Flights.Infrastructure;
using Azaion.Flights.Middleware;
using Azaion.Flights.Services;
const string DatabaseUrlEnvVar = "DATABASE_URL";
const string DatabaseUrlConfigKey = "Database:Url";
var builder = WebApplication.CreateBuilder(args);
var databaseUrl = builder.Configuration["DATABASE_URL"]
?? Environment.GetEnvironmentVariable("DATABASE_URL")
?? "Host=localhost;Database=azaion;Username=postgres;Password=changeme";
var databaseUrl = ConfigurationResolver.ResolveRequiredOrThrow(
builder.Configuration,
DatabaseUrlEnvVar,
DatabaseUrlConfigKey,
"Database connection string");
var connectionString = databaseUrl.StartsWith("postgresql://")
? ConvertPostgresUrl(databaseUrl)
: databaseUrl;
var jwtSecret = builder.Configuration["JWT_SECRET"]
?? Environment.GetEnvironmentVariable("JWT_SECRET")
?? "development-secret-key-min-32-chars!!";
builder.Services.AddScoped(_ =>
{
var options = new DataOptions().UsePostgreSQL(connectionString);
@@ -29,10 +31,22 @@ builder.Services.AddScoped<FlightService>();
builder.Services.AddScoped<WaypointService>();
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 =>
{
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.AddEndpointsApiExplorer();
@@ -40,6 +54,13 @@ builder.Services.AddSwaggerGen();
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())
{
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.
+133
View File
@@ -0,0 +1,133 @@
# 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) ordered by `Name` ASC | `VehicleService.GetVehicles` `OrderBy(a => a.Name)` |
| AC-1.6 | `GET /vehicles?name=&isDefault=` filters **case-INSENSITIVELY** on `Name` (LinqToDB renders `LOWER(name) LIKE %lower(input)%`) and exactly on `IsDefault` | `VehicleService.GetVehicles` `a.Name.ToLower().Contains(query.Name.ToLower())` |
| AC-1.7 | `GET /vehicles/{id}` returns 404 (`KeyNotFoundException``ErrorHandlingMiddleware`) when id absent | `VehicleService.GetVehicle` |
| AC-1.8 | `DELETE /vehicles/{id}` returns 409 (`InvalidOperationException``ErrorHandlingMiddleware`) when any mission references the vehicle | `VehicleService.DeleteVehicle` `IsAny<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), ordered by `CreatedDate` DESC (newest first); `name` filter is **case-INSENSITIVE** (`LOWER(name) LIKE %lower(input)%`) | `MissionService.GetMissions` `OrderByDescending(f => f.CreatedDate)`; `f.Name.ToLower().Contains(query.Name.ToLower())`; default `page=1`, `pageSize=20` |
| AC-2.4 | `GET /missions/{id}` returns 404 when id absent | `MissionService.GetMission` |
| AC-2.5 | `PUT /missions/{id}` applies partial update — non-null fields in `UpdateMissionRequest` overwrite, null fields are preserved | `MissionService.UpdateMission` |
| AC-2.6 | LinqToDB does NOT eager-load `[Association]``Mission.Vehicle` and `Mission.Waypoints` serialize as `null` / `[]` on the wire | `Database/Entities/Mission.cs`; verified observation |
| AC-2.7 | Every `/missions/*` route requires JWT with `permissions=FL` claim | `[Authorize(Policy="FL")]` on `MissionsController` |
| AC-2.8 | TOCTOU on `VehicleId` deletion between existence check and insert is **partly mitigated by DB-level FK**`missions.vehicle_id REFERENCES vehicles(id)` causes PostgreSQL to reject the insert with error code `23503` if the parent was deleted between check and insert. Surface today: `Npgsql PostgresException` (code `23503`) → `ErrorHandlingMiddleware` fallthrough → 500 (UX gap — spec wants 400). Mitigation in app code (wrap check + insert in a transaction OR map `23503` to 400) is carry-forward — tracked in `_docs/02_document/components/02_mission_planning/description.md` Caveats | `MissionService.CreateMission`; `Database/DatabaseMigrator.cs` (FK declaration) |
## AC-3 — Mission delete with cross-service cascade (F3) — **most critical**
| # | Criterion | Verification |
|---|-----------|--------------|
| AC-3.1 | `DELETE /missions/{id}` walks the cascade in this exact order: `map_objects` → resolve `waypointIds` → resolve `mediaIds` (via `media.waypoint_id`) → resolve `annotationIds` (via `annotations.media_id`) → `detection` (by `annotation_id`) → `annotations` (by id) → `media` (by id) → `waypoints` (by `mission_id`) → `missions` (by id) | `MissionService.DeleteMission` (post-B6/B7) |
| AC-3.2 | Mission missing → 404 (`KeyNotFoundException`) **before** any cascade DELETE runs | `MissionService.DeleteMission` initial existence check |
| AC-3.3 | Cascade is **NOT** transaction-wrapped today (ADR-006); partial failure leaves orphan rows in any sub-table | `MissionService.DeleteMission`; no `db.BeginTransactionAsync` |
| AC-3.4 | `relation does not exist` for any of `media` / `annotations` / `detection` → 500 with `LogError`; this is an abnormal deployment (some sibling service hasn't migrated) | `Middleware/ErrorHandlingMiddleware.cs` fallthrough |
| AC-3.5 | After B7 the cascade does NOT touch `orthophotos` or `gps_corrections``gps-denied` owns those tables and lifecycle | post-B7 spec; `_docs/02_document/architecture.md` ADR-007 |
| AC-3.6 | End-to-end latency target: <50ms typical against local PostgreSQL on the same device (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 | **Create**: parent mission missing → 404 (`KeyNotFoundException("Mission not found")`) via an explicit `db.Missions.AnyAsync(m => m.Id == missionId)` check before insert. **Update / Delete**: the check is collapsed into a single composite `WHERE w.MissionId == missionId AND w.Id == waypointId` predicate; if no row matches (parent missing OR child missing OR mismatched parent/child pair) → 404 with the same `Waypoint not found` message. The two error cases (parent vs child) are NOT distinguishable from the response | `WaypointService.{CreateWaypoint, UpdateWaypoint, DeleteWaypoint}` |
| AC-4.3 | `GET /missions/{id}/waypoints` is **unpaginated**, ordered by `OrderNum` ASC (matches spec endpoint 6) | `WaypointService.GetWaypoints` `OrderBy(w => w.OrderNum)` |
| AC-4.4 | `PUT /missions/{id}/waypoints/{wpId}` is a **full overwrite** of every field even though the request DTO looks "partial-shaped" — non-nullable enums/numerics in `UpdateWaypointRequest` mean every field gets replaced (inconsistent with vehicle's nullable partial-update pattern) | `Services/WaypointService.cs` `UpdateWaypoint` + `DTOs/UpdateWaypointRequest.cs` |
| AC-4.5 | `DELETE /missions/{id}/waypoints/{wpId}` walks the same cascade as F3, scoped to one waypoint (`detection``annotations``media``waypoints`) | `WaypointService.DeleteWaypoint` |
| AC-4.6 | Same NO-transaction caveat as AC-3.3 applies to waypoint delete | `WaypointService.DeleteWaypoint` |
| AC-4.7 | Every waypoint route requires JWT with `permissions=FL` claim | `[Authorize(Policy="FL")]` on `MissionsController` |
## AC-5 — JWT bearer validation (F5)
| # | Criterion | Verification |
|---|-----------|--------------|
| AC-5.1 | Algorithm: **ECDSA-SHA256** asymmetric signature validation against public keys retrieved from `admin`'s JWKS. `ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256]` is pinned — defends against HS256-confusion (an attacker who learns the JWKS public key cannot forge tokens with `alg: HS256` using that key as the HMAC secret) | `Auth/JwtExtensions.cs` `TokenValidationParameters.ValidAlgorithms` |
| AC-5.2 | `ValidateLifetime = true`; `ClockSkew = TimeSpan.FromSeconds(30)` (tighter than .NET's 5-minute default and tighter than the legacy 1-minute setting) | `Auth/JwtExtensions.cs` `ClockSkew = TimeSpan.FromSeconds(30)` |
| AC-5.3 | `ValidateIssuer = true` with `ValidIssuer = <resolved JWT_ISSUER>`; `ValidateAudience = true` with `ValidAudience = <resolved JWT_AUDIENCE>`. The CMMC L2 row 3 finding is structurally fixed in this service's code; the suite-level docs may still describe the legacy "iss/aud disabled" model and have a separate sync task pending | `Auth/JwtExtensions.cs` |
| AC-5.4 | Missing `Authorization` header on a `[Authorize]` route → 401 | `JwtBearerHandler` |
| AC-5.5 | Invalid signature → 401 (ECDSA verify fails against every cached public key whose `kid` matches the token header) | `Auth/JwtExtensions.cs` `IssuerSigningKeyResolver` + `JwtBearerHandler` |
| AC-5.6 | Expired token (with 30s skew applied) → 401 | `ValidateLifetime = true` |
| AC-5.7 | Token's `kid` not in cached JWKS → 401. JWKS rotation publishes a new `kid`; the cached manager refreshes on the default schedule (matches admin's `Cache-Control: public, max-age=3600`). **No coordinated redeploy** is needed for rotation | `ConfigurationManager<JsonWebKeySet>` refresh |
| AC-5.8 | Valid signature + lifetime + iss + aud, but missing `permissions=FL` claim → 403 | Policy `"FL"` evaluator (`05_identity/description.md`) |
| AC-5.9 | Request-path validation does NOT call `admin`; only the first protected request after a cold start triggers a synchronous JWKS HTTPS GET against `JWT_JWKS_URL` (which must be HTTPS — `HttpDocumentRetriever { RequireHttps = true }`). Once cached, the manager refreshes on its default schedule. `admin` outage AFTER the JWKS has been cached does NOT take this service down until cache + tokens expire; `admin` outage AT the time of the first JWKS fetch causes the first protected request to fail 500 | `Auth/JwtExtensions.cs` `ConfigurationManager<JsonWebKeySet>` |
| AC-5.10 | Token header with `alg ∉ [EcdsaSha256]` (e.g. forged `alg: HS256`, or genuine but unsupported `alg: RS256`) → 401 — algorithm pin defense | `Auth/JwtExtensions.cs` `ValidAlgorithms` |
| AC-5.11 | `iss` claim ≠ resolved `JWT_ISSUER` → 401 | `Auth/JwtExtensions.cs` `ValidateIssuer` + `ValidIssuer` |
| AC-5.12 | `aud` claim ≠ resolved `JWT_AUDIENCE` → 401 | `Auth/JwtExtensions.cs` `ValidateAudience` + `ValidAudience` |
## AC-6 — Service startup + schema migration (F6)
| # | Criterion | Verification |
|---|-----------|--------------|
| AC-6.1 | `Program.cs` resolves **four** required configuration values via `Infrastructure/ConfigurationResolver.cs``ResolveRequiredOrThrow`: `DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL`. Resolution order per key is env-var-first, then `IConfiguration` config key (`Database:Url` / `Jwt:Issuer` / `Jwt:Audience` / `Jwt:JwksUrl`), else THROW `InvalidOperationException` at startup. **No hardcoded development fallbacks** — ADR-005's "dev fallback secret" branch is obsolete; only the Swagger-unconditional branch remains | `Program.cs`, `Infrastructure/ConfigurationResolver.cs` |
| AC-6.2 | `Program.cs` calls `AddJwtAuth(issuer, audience, jwksUrl)` (NOT `AddJwtAuth(secret)`). The legacy `JWT_SECRET` env var / config key is no longer consulted anywhere in the codebase. JWKS is fetched lazily on the first protected request via `Microsoft.IdentityModel.Protocols.ConfigurationManager<JsonWebKeySet>` with `HttpDocumentRetriever { RequireHttps = true }` | `Program.cs`, `Auth/JwtExtensions.cs` |
| AC-6.3 | `DatabaseMigrator.Migrate` runs ONCE at startup, INSIDE a single startup scope (not per-request) | `Program.cs` `using var scope = app.Services.CreateScope(); ... DatabaseMigrator.Migrate(db)` |
| AC-6.4 | Migrator runs `CREATE TABLE IF NOT EXISTS` for the 4 owned tables (`vehicles`, `missions`, `waypoints`, `map_objects`) using PostgreSQL `TIMESTAMP` (no timezone) for date columns, with explicit `REFERENCES` for FKs (`missions.vehicle_id → vehicles(id)`, `waypoints.mission_id → missions(id)`, `map_objects.waypoint_id → waypoints(id)`), and `CREATE INDEX IF NOT EXISTS` for 3 indexes | `Database/DatabaseMigrator.cs` |
| AC-6.5 | Migrator runs `DROP TABLE IF EXISTS orthophotos; DROP TABLE IF EXISTS gps_corrections;` unconditionally (B9 one-shot kept idempotent indefinitely — re-running on a fresh DB is a no-op) | `Database/DatabaseMigrator.cs` |
| AC-6.6 | Migrator is idempotent — every startup runs the same statements; `IF NOT EXISTS` makes them safe to re-run | `Database/DatabaseMigrator.cs` |
| AC-6.7 | `postgres-local` unreachable at startup → process exits non-zero; Watchtower restarts the container; `flight-gate` prevents restart mid-mission | `Program.cs` (no DB error swallow); suite arch doc |
| AC-6.8 | `azaion` database does not exist → process exits with Npgsql `3D000`; database creation is a provisioning concern, NOT this service | suite-level concern |
| AC-6.9 | After migrator, `ErrorHandlingMiddleware` is registered FIRST in the pipeline — wraps every subsequent middleware exception | `Program.cs` middleware order |
| AC-6.10 | Service serves on port 8080 inside the container (`EXPOSE 8080`); edge compose maps host `5002:8080` | `Dockerfile`; suite `_infra/_compose/` |
| AC-6.11 | CORS is gated by `Infrastructure/CorsConfigurationValidator.cs` at startup: in `Production` (case-insensitive on `ASPNETCORE_ENVIRONMENT`) the host THROWS when `CorsConfig:AllowedOrigins` is empty AND `CorsConfig:AllowAnyOrigin != true`; in non-Production environments the same empty allow-list falls back to permissive (`AllowAnyOrigin/Method/Header`) AND emits a `PermissiveDefaultWarning` startup log. The pre-B11 "all environments permissive" assumption no longer holds | `Program.cs`, `Infrastructure/CorsConfigurationValidator.cs` |
| AC-6.12 | The JWKS HTTPS-only constraint (`HttpDocumentRetriever { RequireHttps = true }`) means a misconfigured `JWT_JWKS_URL = http://...` will pass startup config resolution (any non-empty string is accepted by `ResolveRequiredOrThrow`) but cause the first protected request to fail at JWKS-fetch time → 500. Detected only at runtime, not at startup | `Auth/JwtExtensions.cs` `HttpDocumentRetriever` |
## AC-7 — Health probe (F7)
| # | Criterion | Verification |
|---|-----------|--------------|
| AC-7.1 | `GET /health` is anonymous (no `[Authorize]`) | `Program.cs` `MapGet("/health")` |
| AC-7.2 | Returns `200 OK` with body `{ "status": "healthy" }` | `Results.Ok(new { status = "healthy" })` |
| AC-7.3 | Latency target: <10ms typical (no DB ping today — process-liveness only) | `Program.cs` |
| AC-7.4 | If pipeline is down, the probe fails at TCP-connect time and Watchtower restarts the container | suite arch doc |
## AC-8 — Wire shape (HTTP contract)
| # | Criterion | Verification |
|---|-----------|--------------|
| AC-8.1 | Entity / DTO bodies serialize as **PascalCase** today (no `JsonNamingPolicy.CamelCase` configured) — divergent from suite spec (ADR-002 carry-forward) | `Program.cs` (no `JsonSerializerOptions.PropertyNamingPolicy`); `_docs/02_document/architecture.md` ADR-002 |
| AC-8.2 | Error envelope is camelCase **by accidental match** — middleware writes `new { statusCode, message }` (lowercase property names preserved by System.Text.Json) | `Middleware/ErrorHandlingMiddleware.cs` |
| AC-8.3 | Error envelope **misses** the spec's `errors: object?` field today | `Middleware/ErrorHandlingMiddleware.cs` |
| AC-8.4 | The static `ErrorResponse` DTO is **dead on the wire** — middleware writes the anonymous object instead. If `ErrorResponse` were ever used, it would emit PascalCase + the wrong `Errors` shape (`List<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 **containing** `"FL"` (`AuthorizationPolicyBuilder.RequireClaim("permissions", "FL")` matches when ANY `permissions` claim value equals `"FL"`, so a multi-permission token `permissions: ["FL","SOMETHING_ELSE"]` is accepted) | `Auth/JwtExtensions.cs` `AddPolicy("FL")` |
| AC-9.2 | The string `"FL"` is hardcoded in feature controllers — a typo silently turns into a permanent 403 (no compile-time check) | `Controllers/{Vehicles,Missions}Controller.cs`; `_docs/02_document/module-layout.md` § Verification Needed #4 |
| AC-9.3 | The policy NAME `"FL"` retains the legacy "Flight" wording even after the service rename to `missions` — fleet-wide auth change deferred (NOT in this Epic) | `Auth/JwtExtensions.cs`; `../../suite/_docs/00_roles_permissions.md` TODO |
| AC-9.4 | No per-method authz beyond `[Authorize(Policy="FL")]` — every protected endpoint has the same gate | `Controllers/{Vehicles,Missions}Controller.cs` |
## AC-10 — Operational invariants
| # | Criterion | Verification |
|---|-----------|--------------|
| AC-10.1 | One container instance per device (vertical scale only) | `Dockerfile`; suite arch doc |
| AC-10.2 | RTO ≈ container restart time (~10s); RPO = device-local backup cadence (suite-level) | suite arch doc |
| AC-10.3 | Unhandled 500 exceptions are logged with stack trace via `LogError(ex, "Unhandled exception")` | `Middleware/ErrorHandlingMiddleware.cs` |
| AC-10.4 | No correlation id, no per-user audit log — supporting a production incident requires grep-by-timestamp | `_docs/02_document/architecture.md` § 7 |
| AC-10.5 | The migrator's `DROP TABLE IF EXISTS orthophotos / gps_corrections` block (B9) MUST NOT run before `gps-denied` has migrated its own copy of those tables on the device — out-of-band ordering: deploy `gps-denied` first | `Database/DatabaseMigrator.cs` post-B9; `_docs/02_document/system-flows.md` F6 |
| AC-10.6 | The cross-service cascade (`media`, `annotations`, `detection`) requires `annotations` and detection pipeline to have migrated their tables on the same device — abnormal deployment otherwise | `_docs/02_document/components/02_mission_planning/description.md` Caveats #6 |
@@ -0,0 +1,251 @@
# 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)
All four required values are resolved through `Infrastructure/ConfigurationResolver.cs``ResolveRequiredOrThrow(envName, configKey)`. Resolution order is **env var first**, then `IConfiguration` config key, else **throw `InvalidOperationException` at startup**. There are NO hardcoded dev fallbacks anymore.
| Variable | Config key | Type | Required | Resolution | Format / constraints | Used by |
|----------|------------|------|----------|------------|----------------------|---------|
| `DATABASE_URL` | `Database:Url` | string | yes (always) | `ResolveRequiredOrThrow` | Either `postgresql://user:pass@host:port/db` (converted via local helper `ConvertPostgresUrl`; NO URL-decoding of user/password — credentials with `@`, `:`, `/`, `%` need raw Npgsql form) OR a raw Npgsql connection string | `Program.cs` (DI registration of `AppDataConnection`) |
| `JWT_ISSUER` | `Jwt:Issuer` | string | yes (always) | `ResolveRequiredOrThrow` | The expected `iss` claim value on every accepted JWT; usually the `admin` service's stable identifier | `Program.cs`, `Auth/JwtExtensions.cs``ValidIssuer` |
| `JWT_AUDIENCE` | `Jwt:Audience` | string | yes (always) | `ResolveRequiredOrThrow` | The expected `aud` claim value on every accepted JWT; usually the suite-wide audience identifier shared by all backend validators | `Program.cs`, `Auth/JwtExtensions.cs``ValidAudience` |
| `JWT_JWKS_URL` | `Jwt:JwksUrl` | string | yes (always) | `ResolveRequiredOrThrow` | **HTTPS URL** to `admin`'s JWKS endpoint. `HttpDocumentRetriever { RequireHttps = true }` rejects `http://` at fetch time (not at startup config resolution). Cached via `ConfigurationManager<JsonWebKeySet>`, refreshed on the default schedule | `Program.cs`, `Auth/JwtExtensions.cs` |
| `ASPNETCORE_ENVIRONMENT` | (built-in) | string | no | ASP.NET Core convention | Case-insensitive match on `Production` triggers the CORS strict gate in `CorsConfigurationValidator` | `Program.cs`, `Infrastructure/CorsConfigurationValidator.cs` |
| `CorsConfig:AllowedOrigins` | (config) | string list | conditionally required | `IConfiguration.GetSection("CorsConfig").Get<CorsConfig>()` | List of allowed origins. In `Production`, MUST be non-empty OR `AllowAnyOrigin=true`, else startup throws | `Program.cs`, `Infrastructure/CorsConfigurationValidator.cs` |
| `CorsConfig:AllowAnyOrigin` | (config) | bool | no | same | Opt-in to permissive CORS in production explicitly (use sparingly) | same |
| `AZAION_REVISION` | — | string | no | Dockerfile `ARG` baked from `CI_COMMIT_SHA` | git SHA | Dockerfile only; surfaced via `docker inspect` |
| `ASPNETCORE_URLS` | — | string | no | ASP.NET Core convention | URL list (default `http://+:8080`) | ASP.NET Core host |
**Important**: The legacy `JWT_SECRET` env var is no longer consulted. The ADR-005 "dev fallback secret silently accepted in production" failure mode is structurally eliminated; only the unconditional-Swagger branch of ADR-005 survives.
## 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-INSENSITIVE contains (LOWER(name) LIKE %lower(input)%)
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; } // case-INSENSITIVE contains
public DateTime? FromDate { get; set; }
public DateTime? ToDate { get; set; }
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 20;
// Results ordered by CreatedDate DESC (newest first).
}
```
**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)
FKs in this section are **declared as DB-level `REFERENCES` constraints** in `DatabaseMigrator.cs`, not just logical. Date columns use PostgreSQL `TIMESTAMP` (no timezone, NOT `TIMESTAMPTZ`) — `DateTime.Kind` is normalized to `Unspecified` on read.
### 3.1 `vehicles` (owned)
| Column | Type | Nullable | Default | Notes |
|--------|------|----------|---------|-------|
| `id` | UUID | NO | — | primary key |
| `type` | INTEGER | NO | `0` | `VehicleType` enum int (Plane / Copter / UGV / GuidedMissile) |
| `model` | TEXT | NO | — | |
| `name` | TEXT | NO | — | |
| `fuel_type` | INTEGER | NO | `0` | `FuelType` enum int |
| `battery_capacity` | NUMERIC | NO | `0` | |
| `engine_consumption` | NUMERIC | NO | `0` | |
| `engine_consumption_idle` | NUMERIC | NO | `0` | |
| `is_default` | BOOLEAN | NO | `FALSE` | "exactly one default" enforced by `VehicleService` (stricter than spec — B12 decision) |
### 3.2 `missions` (owned)
| Column | Type | Nullable | Default | Notes |
|--------|------|----------|---------|-------|
| `id` | UUID | NO | — | primary key |
| `created_date` | TIMESTAMP | NO | `NOW()` | server-assigned `UtcNow` if not supplied; `TIMESTAMP` (no timezone) |
| `name` | TEXT | NO | — | |
| `vehicle_id` | UUID | NO | — | `REFERENCES vehicles(id)` — DB-level FK; PostgreSQL error `23503` raised if parent vehicle was deleted between service-layer existence check and insert |
Index: `ix_missions_vehicle_id` on `vehicle_id`.
### 3.3 `waypoints` (owned)
| Column | Type | Nullable | Default | Notes |
|--------|------|----------|---------|-------|
| `id` | UUID | NO | — | primary key |
| `mission_id` | UUID | NO | — | `REFERENCES missions(id)` — DB-level FK |
| `lat` | NUMERIC | YES | — | spec divergence — see § 2.3 |
| `lon` | NUMERIC | YES | — | spec divergence |
| `mgrs` | TEXT | YES | — | spec divergence |
| `waypoint_source` | INTEGER | NO | `0` | `WaypointSource` enum int |
| `waypoint_objective` | INTEGER | NO | `0` | `WaypointObjective` enum int |
| `order_num` | INTEGER | NO | `0` | listing order |
| `height` | NUMERIC | NO | `0` | metres |
Index: `ix_waypoints_mission_id` on `mission_id`.
### 3.4 `map_objects` (owned schema; written by `autopilot`)
| Column | Type | Nullable | Default | Notes |
|--------|------|----------|---------|-------|
| `id` | UUID | NO | — | primary key |
| `mission_id` | UUID | NO | — | `REFERENCES missions(id)` — DB-level FK |
| `h3_index` | TEXT | NO | — | Uber H3 hex grid cell |
| `mgrs` | TEXT | NO | — | |
| `lat` | NUMERIC | YES | — | |
| `lon` | NUMERIC | YES | — | |
| `class_num` | INTEGER | NO | `0` | detection class id |
| `label` | TEXT | NO | `''` | |
| `size_width_m` | NUMERIC | NO | `0` | |
| `size_length_m` | NUMERIC | NO | `0` | |
| `confidence` | NUMERIC | NO | `0` | 0..1 |
| `object_status` | INTEGER | NO | `0` | `ObjectStatus` enum int |
| `first_seen_at` | TIMESTAMP | NO | `NOW()` | `TIMESTAMP` (no timezone) |
| `last_seen_at` | TIMESTAMP | NO | `NOW()` | `TIMESTAMP` (no timezone) |
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; ordered by `Name` ASC; `name` filter is case-INSENSITIVE) |
| `/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>` (ordered by `CreatedDate` DESC; `name` filter case-INSENSITIVE) |
| `/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,218 @@
# 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 (`BR-01`, `BR-02`, `MQ-9` inserted in any order) | List all vehicles ordered by Name ASC (AC-1.5) | `status_code: 200`; body is JSON `array` (NOT `PaginatedResponse`); `body.length == 3`; PascalCase property names; `[v.Name for v in body] == ["BR-01", "BR-02", "MQ-9"]` (alphabetical ASC) | exact (status, length, ordering), 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]`; also `?name=br` (lowercase) | Case-INSENSITIVE substring filter + exact `is_default` (AC-1.6) | both queries: `status_code: 200`; `body.length == 1`; `body[0].Name == "BR-01"` | exact (status, length, value) | N/A | N/A |
| 1.7 | `GET /vehicles?name=ZZ` (substring absent from all names); also `?name=zz` (lowercase) | No-match path of case-INSENSITIVE filter (AC-1.6) | both queries: `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 with deterministic `CreatedDate` values | Default pagination ordered by `CreatedDate` DESC (AC-2.3) | `status_code: 200`; body matches `PaginatedResponse<Mission>` schema; `body.Page == 1`; `body.PageSize == 20`; `body.TotalCount == 25`; `body.Items.length == 20`; for every `i` in `[0..18]`: `Items[i].CreatedDate >= Items[i+1].CreatedDate` (DESC); `?name=re` (lowercase) against missions named `"Recon-*"` returns `TotalCount > 0` (case-INSENSITIVE) | schema, exact (counts, ordering, case-insensitive match) | 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 are obtained via `POST https://jwks-mock:8443/sign { ... }` — the mock signs with its current ECDSA-P-256 private key, publishes the matching public key in its JWKS at `https://jwks-mock:8443/.well-known/jwks.json`. `missions` is configured with `JWT_ISSUER=https://admin-test.azaion.local`, `JWT_AUDIENCE=azaion-edge`, `JWT_JWKS_URL=https://jwks-mock:8443/.well-known/jwks.json`. Default 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 whose signature byte was flipped>` | Invalid signature (AC-5.5) | `status_code: 401` | exact (status) | N/A | N/A |
| 5.2b | `GET /vehicles` with token signed by an ECDSA keypair NOT present in the published JWKS | No matching public key (AC-5.5) | `status_code: 401` | exact (status) | N/A | N/A |
| 5.3 | `GET /vehicles` with token where `exp = now - 60s` (outside 30s skew) | Expired (AC-5.6) | `status_code: 401` | exact (status) | N/A | N/A |
| 5.4 | `GET /vehicles` with token where `exp = now - 15s` (inside 30s skew per AC-5.2) | Within skew | `status_code: 200` | exact (status) | N/A | N/A |
| 5.5 | `GET /vehicles` with valid 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 signature + lifetime, `permissions == "ADMIN"`; also `"fl"`, `"FLight"` | Wrong claim value (AC-9.2) | each: `status_code: 403` | exact (status) | N/A | N/A |
| 5.6b | `GET /vehicles` with valid signature + lifetime, `permissions: ["FL", "ADMIN"]` (multi-permission array) | Contains-match policy accepts (AC-9.1) | `status_code: 200` | exact (status) | N/A | N/A |
| 5.7 | `GET /vehicles` with token where `iss = "https://attacker.example.com"`, otherwise valid | Wrong issuer (AC-5.11) | `status_code: 401` | exact (status) | N/A | N/A |
| 5.7b | `GET /vehicles` with token where `aud = "wrong-audience"`, otherwise valid | Wrong audience (AC-5.12) | `status_code: 401` | exact (status) | N/A | N/A |
| 5.8 | JWKS key rotation: `POST jwks-mock:8443/rotate-key`, immediately replay a token signed with the old `kid` AFTER the JWKS cache refresh tick (≤ 90s) and AFTER `OldKeyGraceSeconds=5` elapses | Cross-rotation invalidation (AC-5.7) — **no missions restart** required | `status_code: 401`; `missions` container's `StartedAt` timestamp unchanged | exact (status, startup timestamp) | N/A | N/A |
| 5.9 | `GET /vehicles` with token forged using `alg: HS256` against the JWKS public key bytes (HS256-confusion attack) | Algorithm pin defense (AC-5.1, AC-5.10) | `status_code: 401` | exact (status) | N/A | N/A |
| 5.10 | Cold start: stop `jwks-mock`, restart `missions`, immediately `GET /vehicles` with a valid (pre-stop-acquired) token | Cold-start dependency on admin reachability (AC-5.9) | `status_code: 500`; log contains JWKS fetch error mentioning HTTPS / connection refused / timeout | exact (status), log_assertion (substring) | 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 **all four required env vars set correctly** (`DATABASE_URL=postgresql://u:p@h:5432/d` URL form, plus `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL`) | URL conversion + fail-fast config resolution (AC-6.1) | service binds `:8080`; `GET /health` returns `200`; logger does NOT emit a config/connection error | log_assertion (no error), exact (health 200) | N/A | N/A |
| 6.1b | Start service with any one of `DATABASE_URL` / `JWT_ISSUER` / `JWT_AUDIENCE` / `JWT_JWKS_URL` unset | Fail-fast on missing required config (AC-6.1, AC-6.2, E3) | process exits non-zero within `≤ 10s`; log contains `InvalidOperationException` referencing the missing env var or config key | exact (non-zero exit), log_assertion (substring) | N/A | N/A |
| 6.1c | Start service with `JWT_JWKS_URL=http://jwks-mock:8443/...` (HTTP not HTTPS); other three set correctly | HTTPS-only JWKS retriever (AC-6.12) | container STARTS (config resolution passes); first protected request returns `status_code: 500` with log line mentioning `RequireHttps` / HTTPS | exact (start ok, first-request 500), log_assertion | N/A | N/A |
| 6.2 | Start service with `DATABASE_URL=Host=h;Database=d;Username=u;Password=p` (raw form), other three set | 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 |
| 6.10 | Start service with `ASPNETCORE_ENVIRONMENT=Production`, empty `CorsConfig:AllowedOrigins`, `CorsConfig:AllowAnyOrigin != true` | CORS Production-gate fail-fast (AC-6.11, E9) | process exits non-zero within `≤ 10s`; log contains `InvalidOperationException` mentioning `CorsConfig` and `Production` | exact (non-zero exit), log_assertion (substring) | N/A | N/A |
| 6.11 | Start service with `ASPNETCORE_ENVIRONMENT=Production`, `CorsConfig:AllowAnyOrigin=true` | Production with explicit any-origin (AC-6.11, E9) | service starts; logs may include a warning about permissive CORS in Production but no throw | log_assertion (no throw) | N/A | N/A |
| 6.12 | Start service with `ASPNETCORE_ENVIRONMENT=Production`, `CorsConfig:AllowedOrigins=["https://operator.example.com"]` | Production with explicit allow-list (AC-6.11, E9) | service starts; `OPTIONS /vehicles` preflight from `https://operator.example.com` returns `200` with the corresponding `Access-Control-Allow-Origin` echo; preflight from `https://attacker.example.com` returns without the echo | exact (preflight echo present / absent) | N/A | N/A |
| 6.13 | Start service with `ASPNETCORE_ENVIRONMENT=Test` (or any non-Production), empty `CorsConfig:AllowedOrigins` | Non-Production permissive fallback (AC-6.11, E9) | service starts; logs contain `PermissiveDefaultWarning`; `OPTIONS /vehicles` from any origin gets `200` with echo | log_assertion (warning), exact (preflight) | 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"` (single string OR array containing `"FL"`) | Policy "FL" satisfies via contains-match (AC-9.1, AC-9.4) | each call: `status_code``{200, 201, 204}` (no 401/403); a token with `permissions: ["FL", "ADMIN"]` is ALSO accepted | exact (status set) | N/A | N/A |
| 9.2 | Any protected endpoint called with `permissions == "fl"` (lowercase) or `"FLight"` or `"ADMIN"` | 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 using ECDSA-SHA256 and validated locally against `admin`'s JWKS, which `missions` fetches once at startup (and refreshes on schedule) and caches; request-path validation is local and does not call 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. Once the JWKS has been cached, authn/authz does not depend on a live `admin` callback; tokens validate locally against the cached ECDSA public keys. `admin` must be reachable at the moment of the first JWKS fetch after a cold start (then again periodically on the manager's refresh schedule).
## 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 + JWKS publisher) | This service fetches admin's public JWKS once at startup and on the `ConfigurationManager` refresh schedule; request-path validation does not call back. `admin` outage after the JWKS has been cached does NOT take this service down (until cache + tokens expire). `admin` outage at the time of the first JWKS fetch causes the first protected request to fail 500 |
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 ECDSA-SHA256 against admin's cached JWKS; `iss` + `aud` validated; `alg` pinned)
- **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 ECDSA-SHA256 signature verification against admin's published JWKS (cached locally), with `iss == JWT_ISSUER` + `aud == JWT_AUDIENCE` + `exp` (30s skew) all enforced and `alg` pinned to `EcdsaSha256`; 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 | Four required env vars at runtime: `DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL`. Each resolved via `Infrastructure/ConfigurationResolver.cs``ResolveRequiredOrThrow` (env-first, then `IConfiguration` config key `Database:Url` / `Jwt:Issuer` / `Jwt:Audience` / `Jwt:JwksUrl`, else throw at startup). The legacy `JWT_SECRET` env var is no longer consulted | `Program.cs`, `Auth/JwtExtensions.cs`, `Infrastructure/ConfigurationResolver.cs` |
| E2 | `DATABASE_URL` accepts either a `postgresql://user:pass@host:port/db` URL OR a raw Npgsql connection string (local helper `ConvertPostgresUrl`). `ConvertPostgresUrl` does NOT URL-decode user/password — credentials with `@`, `:`, `/`, `%` need raw Npgsql form | `Program.cs` `ConvertPostgresUrl` |
| E3 | **No hardcoded development fallbacks.** `ResolveRequiredOrThrow` throws `InvalidOperationException` at startup if any of `DATABASE_URL` / `JWT_ISSUER` / `JWT_AUDIENCE` / `JWT_JWKS_URL` is missing or whitespace-only. ADR-005's "dev fallback secret" branch is obsolete; only the Swagger-unconditional branch remains | `Infrastructure/ConfigurationResolver.cs`; `Program.cs` |
| E4 | JWT signature validation is asymmetric (ECDSA-SHA256) against the JWKS at `JWT_JWKS_URL`. `admin` holds the private key; this service caches the public JWKS via `Microsoft.IdentityModel.Protocols.ConfigurationManager<JsonWebKeySet>` (fetched at startup, refreshed on default schedule, HTTPS-only via `HttpDocumentRetriever { RequireHttps = true }`). **JWKS rotation does NOT require a coordinated redeploy** — consumers pick up the new keys at the next refresh tick | `Auth/JwtExtensions.cs`; `_docs/02_document/components/05_identity/description.md` |
| 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` today, but `IConfiguration` lookups (e.g. `Database:Url`, `Jwt:Issuer`) are wired so adding `appsettings.*.json` later requires no code changes | `Program.cs`; no `appsettings.*.json` in repo |
| E9 | CORS is gated by `Infrastructure/CorsConfigurationValidator.cs`. In `Production` (case-insensitive on `ASPNETCORE_ENVIRONMENT`) startup THROWS when `CorsConfig:AllowedOrigins` is empty AND `CorsConfig:AllowAnyOrigin != true`. In non-Production environments, an empty allow-list with `AllowAnyOrigin=false` falls back to permissive (`AllowAnyOrigin/Method/Header`) and emits the `PermissiveDefaultWarning` startup log. The "all environments permissive" claim no longer holds | `Program.cs`, `Infrastructure/CorsConfigurationValidator.cs` |
| E10 | TLS termination is the suite reverse proxy's responsibility — container exposes plain HTTP on `:8080`. The JWKS fetch itself is independently constrained to HTTPS (`RequireHttps = true`) | `Dockerfile`; suite arch doc; `Auth/JwtExtensions.cs` |
## 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 | **Now implemented in this service's code** (`ValidateIssuer=true` against `JWT_ISSUER`, `ValidateAudience=true` against `JWT_AUDIENCE`). The CMMC L2 row 3 finding is structurally fixed here; suite-level docs may still describe the legacy model and have a separate sync task pending | n/a — no longer a carry-forward in this repo |
| camelCase wire-shape migration | Suite-wide cutover (ADR-002 carry-forward) | All-or-nothing; UI + autopilot consume PascalCase today |
+152
View File
@@ -0,0 +1,152 @@
# 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 (**ECDSA-SHA256**) with public-key validation against `admin`'s JWKS endpoint. After the first successful JWKS fetch, request-path validation is purely local (no per-request call to `admin`).
**Trust model**: `admin` holds the ECDSA **private** key; every backend service on each edge device validates with the corresponding **public** keys, fetched from `admin`'s JWKS endpoint (`JWT_JWKS_URL`). Rotation publishes a new `kid` in the JWKS; consumers pick it up at the next refresh tick — **NO coordinated redeploy** required (one of the primary operational wins over the legacy HS256 model).
**Validation parameters** (`Auth/JwtExtensions.cs`):
| Parameter | Value | Notes |
|-----------|-------|-------|
| `ValidAlgorithms` | `[SecurityAlgorithms.EcdsaSha256]` | Algorithm pin — defends against HS256-confusion attacks (an attacker who learns the JWKS public key cannot forge tokens signed with `alg: HS256` using that key as the HMAC secret) |
| `IssuerSigningKeyResolver` | Pulls keys from cached `JsonWebKeySet` retrieved via `ConfigurationManager<JsonWebKeySet>` | Lazily fetches on first protected request; cache refreshes on the manager's default schedule matched against admin's `Cache-Control: public, max-age=3600` |
| JWKS HTTP transport | `HttpDocumentRetriever { RequireHttps = true }` | HTTPS-only — a misconfigured `JWT_JWKS_URL = http://...` fails at fetch time, not at startup config resolution |
| `ValidateLifetime` | `true` | Tokens with `exp` in the past are rejected |
| `ClockSkew` | `TimeSpan.FromSeconds(30)` | Tighter than .NET's 5-min default AND tighter than the legacy 1-min setting |
| `ValidateIssuer` | **`true`** with `ValidIssuer = <resolved JWT_ISSUER>` | **CMMC L2 row 3 finding structurally fixed in this service's code.** Suite-level docs may still describe the legacy "disabled" model and have a separate sync task pending |
| `ValidateAudience` | **`true`** with `ValidAudience = <resolved JWT_AUDIENCE>` | Same as above |
| `ValidateIssuerSigningKey` | `true` (implicit via `IssuerSigningKeyResolver`) | Required for asymmetric validation |
**Failure outcomes**:
| Condition | HTTP code |
|-----------|-----------|
| Missing `Authorization` header on `[Authorize]` route | 401 |
| Invalid signature (no public key in cached JWKS verifies the token) | 401 |
| Token `kid` not in cached JWKS (rotation lag before refresh tick) | 401 (resolved on next JWKS refresh) |
| Token `alg``[EcdsaSha256]` (e.g. forged `alg: HS256`) | 401 (algorithm pin) |
| Expired token (with 30s skew) | 401 |
| `iss` claim ≠ `JWT_ISSUER` | 401 |
| `aud` claim ≠ `JWT_AUDIENCE` | 401 |
| Valid signature + lifetime + iss + aud, but missing `permissions=FL` claim | 403 |
| `JWT_JWKS_URL` uses `http://` (not `https://`) | 500 on first protected request (HTTPS-only retriever throws); NOT caught at startup |
| `admin` unreachable AT the time of the first protected request after cold start | 500 on that first request (synchronous JWKS fetch fails); resolves once admin is reachable |
**`admin` outage AFTER JWKS cached**: tokens issued before the outage continue to validate locally against the cached public keys. This service does **not** require `admin` to be reachable for any request-path flow once the JWKS cache is warm. Once issued tokens expire, new logins fail at `admin`'s end (UI concern), but this service stays up until the cache itself expires and a refresh fails.
**`admin` outage AT cold start**: the first protected request triggers a synchronous JWKS HTTPS GET; if `admin` is unreachable at that moment, the request fails 500. This is a **new failure mode** introduced by the ECDSA-JWKS switch and is the cost of the rotation-without-redeploy operational win.
## 2. Authorization
**Single named policy**: `"FL"`. Every controller action in `01_vehicle_catalog` and `02_mission_planning` carries `[Authorize(Policy = "FL")]`. The policy is built as `AuthorizationPolicyBuilder.RequireClaim("permissions", "FL")` — satisfied when ANY `permissions` claim on the principal equals `"FL"`. A multi-permission token (`permissions: ["FL", "SOMETHING_ELSE"]`) is accepted.
**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**:
- This service no longer holds a JWT signing secret. It holds **only** public-key configuration (`JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL`) plus `DATABASE_URL`. Whichever side gets compromised, that compromise no longer affects token signing.
- All four required values (`DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL`) are resolved through `Infrastructure/ConfigurationResolver.cs``ResolveRequiredOrThrow` (env-var-first, then `IConfiguration` key, else THROW). **No hardcoded dev fallbacks** — the ADR-005 "dev fallback secret silently accepted in production" failure mode is structurally eliminated. ADR-005 now only covers the unconditional-Swagger branch.
- No secret manager (Vault, AWS SM, K8s Secrets) — config values are baked into the device's docker compose env at provisioning time.
## 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
**Gated by `Infrastructure/CorsConfigurationValidator.cs`** at startup:
- In `Production` (case-insensitive match on `ASPNETCORE_ENVIRONMENT`): startup **THROWS `InvalidOperationException`** when `CorsConfig:AllowedOrigins` is empty AND `CorsConfig:AllowAnyOrigin != true`. The "open in all environments" failure mode is structurally eliminated.
- In non-Production environments: the same empty allow-list with `AllowAnyOrigin=false` falls back to permissive (`AllowAnyOrigin/Method/Header`) AND emits a `PermissiveDefaultWarning` startup log. The pre-B11 "all environments permissive" assumption no longer holds.
- An explicit `AllowedOrigins` list narrows CORS to those origins in every environment.
The closed edge network behind the suite reverse proxy is still the deployment-shape backstop, but the application now refuses to start in production without an explicit policy decision.
## 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 |
|---------|-------|------------|
| **Cold-start dependency on `admin` reachability**: first protected request after a cold start triggers a synchronous JWKS HTTPS GET against `JWT_JWKS_URL`. If `admin` is unreachable at that moment, the request fails 500. Once cached, request-path is local-only | `Auth/JwtExtensions.cs` `ConfigurationManager<JsonWebKeySet>` | Document operational expectation; consider pre-warming the JWKS cache during startup if cold-start failure modes become disruptive |
| **`JWT_JWKS_URL` misconfigured as `http://`** passes startup config resolution but fails at first JWKS fetch → 500 | `Auth/JwtExtensions.cs` `HttpDocumentRetriever { RequireHttps = true }` | Detected at runtime, not startup; recommend a startup smoke check that validates the URL scheme before serving traffic |
| **Swagger UI mounted unconditionally** | `Program.cs` (no `IsDevelopment` gate, ADR-005 surviving branch) | Reverse-proxy-level allowlist on `/swagger` is the suite-level mitigation; verify on first production rollout |
| **CORS allow-list empty in non-Production** falls back to permissive (`AllowAnyOrigin/Method/Header`) with a startup warning | `Infrastructure/CorsConfigurationValidator.cs` | Document explicit `CorsConfig:AllowedOrigins` for staging/dev too if permissive is undesirable. **Production fails-fast** — no remediation needed there |
| **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) |
| **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` |
**Removed from this list** (previously listed, now structurally fixed by code, not by mitigation):
- ❌ Dev fallback for `JWT_SECRET``JWT_SECRET` env var is no longer consulted; the resolver throws on missing required config.
- ❌ Dev fallback for `DATABASE_URL` — same resolver throws on missing required config.
- ❌ CORS `AllowAnyOrigin/Method/Header` in production — production startup throws on empty allow-list with `AllowAnyOrigin != true`.
- ❌ JWT `iss`/`aud` validation disabled — both are now validated; CMMC L2 row 3 finding structurally fixed in this service's code.
## 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 — remains the primary defence-in-depth layer, but the application-layer auth posture has materially improved: ECDSA-SHA256 JWT validation against `admin`'s JWKS (with algorithm pinning, `iss`/`aud` validation, HTTPS-only JWKS fetch, and a 30s clock skew), production-gated CORS, and fail-fast required-config resolution have collectively eliminated the dev-fallback, iss/aud-disabled, and CORS-permissive-in-prod footguns that the legacy HS256 model carried. Residual application-level weak points (no input validation, no per-user audit, hardcoded `"FL"` string, cold-start dependency on `admin` reachability for the first protected request, non-transactional cascade delete) are documented and tracked. The CMMC L2 row 3 finding is structurally closed in this service's code; suite-level documentation may still describe the legacy posture and has a separate sync task pending.
## 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 / config resolution (fail-fast) | `Program.cs`, `Infrastructure/ConfigurationResolver.cs` |
| CORS validation | `Infrastructure/CorsConfigurationValidator.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 unconditional, surviving branch) | `_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` |
+219
View File
@@ -0,0 +1,219 @@
# 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 (ECDSA-signed) and validated locally by `missions` against `admin`'s JWKS endpoint — request-path validation is local after the JWKS is cached, but the first protected request after a cold start triggers a synchronous JWKS HTTPS GET against `admin`. Key rotation publishes a new `kid` in `admin`'s JWKS and propagates to validators on the cache-refresh tick (no coordinated redeploy).
### 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 -- "JWKS over HTTPS (lazy fetch + refresh)" --> 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(issuer, audience, jwksUrl)` registers `JwtBearer` with **ECDSA-SHA256** (algorithm pin), iss + aud validation, `ClockSkew = 30s`, and the named policy `"FL"`. Signing keys are pulled from `admin`'s JWKS via `ConfigurationManager<JsonWebKeySet>` with `HttpDocumentRetriever { RequireHttps = true }` | `Microsoft.AspNetCore.Authentication.JwtBearer` 10.0.5, `Microsoft.IdentityModel.Protocols`, `JsonWebKeySet` | `admin` outage AFTER the JWKS is cached does NOT take this service down; key rotation publishes a new `kid` and propagates on the refresh tick — **no coordinated redeploy**; iss + aud + alg-pin closes the CMMC L2 row 3 finding in this service's code | First protected request after a cold start triggers a synchronous JWKS fetch → if `admin` is unreachable at that exact moment the request 500s (new failure mode vs the legacy local-only model); the policy code `"FL"` retains the legacy "Flight" wording (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` | Asymmetric: `admin` holds the private key; this service holds only public-key configuration + the `JWT_ISSUER` / `JWT_AUDIENCE` / `JWT_JWKS_URL` env vars (no shared secret on this side anymore) | One file (~80 LoC) | **Good**; the cold-start dependency on `admin` reachability is the cost of the rotation-without-redeploy operational win |
| 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: resolve four required config values via `Infrastructure/ConfigurationResolver.ResolveRequiredOrThrow` (`DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL`); env → Npgsql connection string adapter (`ConvertPostgresUrl`); JWT registration; scoped DI for `AppDataConnection` + service classes; run migrator at startup; `CorsConfigurationValidator.EnsureSafeForEnvironment` gating CORS; mount middleware in correct order; `MapGet("/health")`; mount Swagger | ASP.NET Core minimal host APIs, `Infrastructure/ConfigurationResolver.cs`, `Infrastructure/CorsConfigurationValidator.cs` | One file you can read top-to-bottom in one sitting; **fail-fast on missing required config** — no silent boot with insecure defaults; **CORS gated by environment** — Production refuses an empty allow-list unless `AllowAnyOrigin=true` | Swagger UI is still NOT gated on `IsDevelopment()` (surviving branch of ADR-005); a misconfigured `JWT_JWKS_URL = http://...` passes config resolution but fails at first JWKS fetch (detected at runtime, not startup) | Spec § service composition; container `EXPOSE 8080`; Watchtower restart contract | Required config is loud-fail (`InvalidOperationException`) on absence; **no hardcoded dev fallbacks anywhere**. Swagger surviving branch remains a tracked carry-forward | One file (~180 LoC) plus the two `Infrastructure/*.cs` helpers (~70 LoC together) | **Good** — the security posture is materially improved over the pre-2026-05 state |
### 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) |
| JWT validation against `admin` JWKS, request-path local after cache (ADR-005, F5) | Asymmetric trust + rotation-without-redeploy; closes the CMMC L2 iss/aud finding in this service's code while keeping `admin` off the per-request hot path | **Implemented** (ECDSA-SHA256 with algorithm pinning, iss + aud validation, HTTPS-only JWKS retrieval, cold-start synchronous fetch trade-off documented) |
| 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. The 2026-05-14 re-verification confirmed that the JWT/CORS/Config evolution actually made the code MORE testable than the docs described (env-first `ResolveRequiredOrThrow`, JWKS retrievable via an in-process ECDSA keypair + ephemeral JWKS HTTP service mock, explicit CORS config), so this step is expected to land "all scenarios testable as-is". 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 ECDSA-SHA256 / reject `alg ∉ [EcdsaSha256]` (HS256-confusion) / reject invalid signature / reject mismatched `kid` / reject expired (with 30s skew) / reject `iss != JWT_ISSUER` / reject `aud != JWT_AUDIENCE` / reject missing-`FL` claim / JWKS rotation picks up new `kid` on refresh tick | F5 cross-cutting; pins the asymmetric-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` |
| Configuration / CORS gates | `Infrastructure/ConfigurationResolver.cs`, `Infrastructure/CorsConfigurationValidator.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` |
| Drift findings (2026-05-14 re-verification) | `_docs/02_document/05_drift_findings_2026-05-14.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.
+172
View File
@@ -0,0 +1,172 @@
# 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 | **REISSUED 2026-05-14** — ECDSA-SHA256 against admin's JWKS (cached via `ConfigurationManager<JsonWebKeySet>` with `HttpDocumentRetriever{RequireHttps=true}` + private `JwksRetriever`); `ValidateIssuer = true` against `JWT_ISSUER`; `ValidateAudience = true` against `JWT_AUDIENCE`; `ClockSkew = 30 seconds`; `ValidAlgorithms = [EcdsaSha256]`; `RequireSignedTokens = true`; `RequireExpirationTime = true`. Single `"FL"` policy post-B7 | `Auth/JwtExtensions.cs` matches the reissued claim exactly; today has BOTH `"FL"` and `"GPS"` policies | ✓ B7 drops `"GPS"`. **The previous verdict ("matches exactly" against the HS256 / shared-secret doc) was wrong** — the underlying docs were stale; corrected via the 2026-05-14 re-verification pass and rewritten in `modules/auth.md`, `components/05_identity/description.md`, `diagrams/flows/flow_jwt_validation.md`, `architecture.md` § 7 + Tech Stack, `system-flows.md` Cross-cutting #1 + F5, and `00_problem/*` (see § 4.3 below) |
| F6 Startup + migration | **REISSUED 2026-05-14**`Program.cs` builds host → `ConfigurationResolver.ResolveRequiredOrThrow` resolves `DATABASE_URL` (with `ConvertPostgresUrl`) → resolves `JWT_ISSUER` + `JWT_AUDIENCE` + `JWT_JWKS_URL` (all required, no fallback) → registers scoped services + JWT bearer + JWKS `ConfigurationManager` → reads `CorsConfig:AllowedOrigins` + `CorsConfig:AllowAnyOrigin``CorsConfigurationValidator.EnsureSafeForEnvironment` (throws in Production with implicit-permissive config) → registers CORS policy (permissive OR `WithOrigins`) → migrates → starts. Pipeline: `ErrorHandlingMiddleware FIRST → Cors → Authentication → Authorization → Swagger → MapControllers → MapGet("/health") → Run`. May emit `PermissiveDefaultWarning` startup log when implicit-permissive CORS applies | `Program.cs` matches the reissued claim exactly; service registration is `FlightService, WaypointService, AircraftService` today instead of `Mission/Waypoint/VehicleService` | ✓ B5+B6 rename + DI re-registration. **The previous verdict ("matches exactly" against docs claiming hardcoded `JWT_SECRET` fallback + unconditional permissive CORS) was wrong** — corrected via the 2026-05-14 re-verification pass and rewritten in `modules/program.md`, `components/07_host/description.md`, `diagrams/flows/flow_startup_migration.md`, `architecture.md` § 3 deployment table + ADR-005, and `system-flows.md` F6 |
| F7 Health probe | `MapGet("/health", () => Results.Ok(new { status = "healthy" }))`, anonymous | identical | ✓ no rename gap |
All flow claims reconcile after the 2026-05-14 reissue. ✓
## 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 unconditional, etc.) — **note: "CORS unconditional" was REMOVED from this list on 2026-05-14**. CORS is gated by `Infrastructure/CorsConfigurationValidator.cs`; it throws in Production with implicit-permissive config and falls back to permissive (with `PermissiveDefaultWarning`) only in non-Production. See § 4.3 below | All remaining items are real, already documented with their resolution path | These are the *intentional* carry-forward items |
### 4.3 Re-verification pass on 2026-05-14 (targeted)
While preparing autodev Step 4 (Code Testability Revision), a targeted code-level cross-check of `Auth/JwtExtensions.cs`, `Program.cs`, `Infrastructure/{ConfigurationResolver, CorsConfigurationValidator}.cs`, `Database/DatabaseMigrator.cs`, and `Services/*.cs` against the corresponding `_docs/` artifacts surfaced that the original § 3 verdicts for F5 (JWT) and F6 (Startup) had been performed **doc-vs-doc** rather than against actual source. The actual code state is materially different from what the docs described. The findings were captured in `_docs/02_document/05_drift_findings_2026-05-14.md`; the doc revisions applied in this pass:
| Doc | Sections rewritten |
|-----|--------------------|
| `modules/auth.md` | Full rewrite — ECDSA + JWKS + `ConfigurationManager` + iss/aud + 30s skew + alg pin; no fallback |
| `modules/program.md` | Internal Logic block; Configuration table (6 keys); Security; External Integrations; Notes |
| `modules/database.md` | Internal Logic — explicit `TIMESTAMP` (not `TIMESTAMPTZ`), explicit `REFERENCES`, explicit `DEFAULT` clauses |
| `components/05_identity/description.md` | Full rewrite — same scope as `modules/auth.md` |
| `components/07_host/description.md` | Header source-of-truth note; Implementation Details (Configuration table + CORS gating); Caveats |
| `diagrams/flows/flow_jwt_validation.md` | Full rewrite — new sequence with JWKS resolver + algorithm-pin step + iss/aud branches |
| `diagrams/flows/flow_startup_migration.md` | Preconditions + sequence + flowchart + data-flow + error-scenarios — 4 required env vars + CORS gate |
| `architecture.md` | Architecture Vision; § 5 External Integrations; § 7 Security Architecture; § 3 Environment-specific config table; Tech Stack JWT row; ADR-005 (scope reduced) |
| `data_model.md` | § 5 ERD — column-type annotations; § 6 Owned-table invariants — explicit FK / TIMESTAMP notes |
| `system-flows.md` | Cross-cutting #1 (JWT); F5 sequence + error table; F6 sequence + error table |
| `04_verification_log.md` (this file) | § 3 rows F5 + F6 reissued; § 4.2 row F3 corrected; this § 4.3 block added |
| `00_problem/*` (Phase 2 — next session) | AC-5 group, AC-6.1/6.2, AC-9.1, AC-1.5/1.6/2.3, E1, E3, E4, E9 — see `05_drift_findings_2026-05-14.md` Phase 2 |
| `_docs/02_document/tests/*` (Phase 2 — next session) | environment.md (JWKS mock), test-data.md, blackbox-tests.md (case-insensitive + ordering), security-tests.md (full NFT-SEC revision), resilience-tests.md (NFT-RES-05 + NFT-RES-07), traceability-matrix.md — see drift findings Phase 2 |
**Root cause** (recorded in `_autodev_state.md` for the retrospective): the prior verification step did doc-vs-doc consistency checks for these areas instead of opening the actual `.cs` files. The docs were internally consistent describing a stale HS256 / shared-secret / permissive-CORS / dev-fallback world that no longer exists in code. Subsequent verification passes (Step 4 prep, this reissue) must open source files for any flow whose verdict is "matches exactly" and explicitly note which files were read.
## 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, after the 2026-05-14 reissue corrected the F5 (JWT) and F6 (Startup) flow descriptions to match actual code.
- One **systematic doc-internal inconsistency** was found and fixed in the initial pass: the global error envelope's wire-shape case-style was misstated as PascalCase across 8 files when the middleware actually emits camelCase.
- One **doc-vs-code drift** was found and fixed in the 2026-05-14 reissue: the JWT model (ECDSA + JWKS + iss/aud + 30s skew + alg pin, fail-fast on missing env), the configuration model (`ResolveRequiredOrThrow` — no hardcoded fallbacks), the CORS model (gated; Production hard-fail), and the DB schema details (TIMESTAMP, REFERENCES, DEFAULTs). The downstream test-spec re-issue is queued for the next autodev session (Phase 2 in `05_drift_findings_2026-05-14.md`).
- No hallucinated entities or methods. No missing module or component coverage.
**Outcome**: docs are now accurate as the spec for B5B12 AND faithful to the actual current behavior of the JWT / config / CORS / DB-schema surfaces. The next autodev pass continues from Phase 2 (test-spec scoped re-issue), then Phase 3 (resume Step 4 — Code Testability Revision).
@@ -0,0 +1,152 @@
# Drift Findings — Targeted Verification Re-run, 2026-05-14
**Status**: discovery complete; **doc revisions and test-spec re-issues PENDING** (next session).
**Scope**: targeted re-verification of `Auth/JwtExtensions.cs`, `Program.cs`, `Infrastructure/*.cs`, `Services/*.cs`, `Database/DatabaseMigrator.cs`, `Middleware/ErrorHandlingMiddleware.cs`, `Controllers/FlightsController.cs`, `Controllers/AircraftsController.cs` against the corresponding `_docs/` artifacts.
**Trigger**: while preparing autodev Step 4 (Code Testability Revision), ran a code-level cross-check that contradicts the `_docs/02_document/04_verification_log.md` § 3 "All flow claims reconcile" verdict for F5 (JWT) + F6 (Startup) + AC-9 (Authz).
**Root cause** (likely): the prior verification did doc-vs-doc consistency checks for these areas instead of opening the actual `.cs` files. The docs are internally consistent describing a HS256+shared-secret+permissive-CORS+dev-fallback world that no longer exists in the code.
**Decision (user, this turn)**: Option A — re-run /document Step 4 targeted at the drifted areas, then re-issue test-spec for the affected ACs.
---
## Drift NOT covered by the B-ticket rename mapping
These are real findings. Each item is actual today-code state, NOT a "post-rename target". The `_docs/` and the test specs in `_docs/02_document/tests/` need updates to match.
### D-JWT — Auth/JwtExtensions.cs (MAJOR)
| # | Aspect | Doc claim (AC-5.*, modules/auth.md, components/05_identity, architecture.md § 7) | Code today (`Auth/JwtExtensions.cs`) |
|---|--------|----------------------------------------------------------------------------------|--------------------------------------|
| J1 | Algorithm | HS256 (`SymmetricSecurityKey(UTF-8(JWT_SECRET))`) | **ECDSA-SHA256** (`ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256]`) with JWKS keys |
| J2 | Key material | Shared HMAC secret in `JWT_SECRET` env var | **JWKS retrieved from `admin`** via `ConfigurationManager<JsonWebKeySet>` + `JwksRetriever` + `HttpDocumentRetriever { RequireHttps = true }` |
| J3 | Env var contract | `JWT_SECRET` (single var) | **Three vars**: `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL` (NO `JWT_SECRET`) |
| J4 | `ValidateIssuer` | `false` | **`true`** + `ValidIssuer = <JWT_ISSUER>` |
| J5 | `ValidateAudience` | `false` | **`true`** + `ValidAudience = <JWT_AUDIENCE>` |
| J6 | `ClockSkew` | `1 minute` | **`30 seconds`** |
| J7 | Pinned algorithms | not mentioned | **`ValidAlgorithms = [EcdsaSha256]`** (forces algo to prevent HS256-confusion attack) |
| J8 | `RequireSignedTokens` / `RequireExpirationTime` | not explicitly mentioned | both `true` |
| J9 | Coupling to `admin` | "Local validation; this service never calls back to `admin`" | **Calls `admin` for JWKS** at startup + on `ConfigurationManager` refresh schedule |
| J10 | Rotation model | "Token signed with old `JWT_SECRET` → 401 across the entire device until coordinated re-deploy" | **JWKS rotation on `admin`** + auto-refresh; no coordinated re-deploy needed |
| J11 | Dev fallback | `JWT_SECRET=development-secret-key-min-32-chars!!` if env unset (ADR-005 carry-forward) | **No fallback**; `ConfigurationResolver.ResolveRequiredOrThrow` throws at startup if any of `JWT_ISSUER`/`JWT_AUDIENCE`/`JWT_JWKS_URL` is unset |
| J12 | Authz policies | Single `"FL"` policy; `"GPS"` is the post-B7 target (existing-code has both today) | **Today has both `"FL"` AND `"GPS"`** — matches what the verification log already says, kept here for completeness |
**ACs / NFTs to revise**: AC-5.1, AC-5.2, AC-5.3, AC-5.4, AC-5.5, AC-5.6, AC-5.7, AC-5.9; NFT-SEC-0109; NFT-RES-07; FT-N-08; results_report.md AC-5 entire group; environment.md JWT mock spec; test-data.md JWT mint section.
### D-CONFIG — Program.cs + Infrastructure/ConfigurationResolver.cs (MAJOR)
| # | Aspect | Doc claim | Code today |
|---|--------|-----------|------------|
| C1 | Required env vars | Two: `DATABASE_URL`, `JWT_SECRET` (E1) | **Four**: `DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL` |
| C2 | Configuration source order | `IConfiguration``Environment.GetEnvironmentVariable` → fallback | **Env var first** (`Environment.GetEnvironmentVariable`), then `IConfiguration` key (e.g. `Database:Url`, `Jwt:Issuer`), then **throw** (no fallback) |
| C3 | Dev fallbacks for `JWT_SECRET` / `DATABASE_URL` | "ungated by `IsDevelopment()`; production deploy without env vars silently boots with the dev secret" (ADR-005 carry-forward) | **No fallbacks at all**; production deploy without env vars now throws `InvalidOperationException` at startup. ADR-005 is OBSOLETE for this aspect |
| C4 | `Database:Url` config-key alternative | not mentioned in docs (env-only) | **Code reads `Database:Url`** as fallback to `DATABASE_URL` env var |
| C5 | `Jwt:Issuer` / `Jwt:Audience` / `Jwt:JwksUrl` config-key alternatives | not mentioned | **Code reads each** as fallback to its env var |
**ACs / NFTs to revise**: AC-6.1, AC-6.2, E1, E3, E4 (no shared secret anymore); NFT-RES-05 (still tests DB-down crash, but the failure mode is more direct now); environment.md "Test Execution" env var list; test-data.md env vars; results_report.md AC-6 group + E3 lock test.
### D-CORS — Infrastructure/CorsConfigurationValidator.cs (MAJOR)
| # | Aspect | Doc claim (E9) | Code today |
|---|--------|----------------|------------|
| O1 | Permissive policy scope | "`AllowAnyOrigin / AllowAnyMethod / AllowAnyHeader` in **all** environments (assumed safe behind suite reverse proxy)" | **Conditionally** permissive: `EnsureSafeForEnvironment` THROWS in `Production` if `CorsConfig:AllowedOrigins` is empty AND `CorsConfig:AllowAnyOrigin != true`. Permissive only when explicit opt-in OR non-Production |
| O2 | Config keys | not mentioned | New keys: **`CorsConfig:AllowedOrigins`** (string array) and **`CorsConfig:AllowAnyOrigin`** (bool) |
| O3 | Warning behavior | not mentioned | Logs `PermissiveDefaultWarning` at startup when implicit-permissive applies (origins empty + AllowAnyOrigin=false + non-Production) |
**ACs / NFTs to revise**: E9 restriction; results_report.md E9 lock test (currently doesn't exist; was deferred to follow-up); ADR-002 in architecture.md only if it discusses CORS.
### D-DBSCHEMA — Database/DatabaseMigrator.cs (SMALL)
| # | Aspect | Doc claim (data_parameters.md § 3) | Code today |
|---|--------|------------------------------------|------------|
| S1 | `created_date` / `first_seen_at` / `last_seen_at` column type | `TIMESTAMPTZ` | **`TIMESTAMP`** (no timezone) — affects how DateTime kinds round-trip |
| S2 | Foreign-key declarations | "logical FK ... no DB-level FK constraint declared in migrator" (data_parameters.md § 3.2 + § 3.3 note) | **`REFERENCES <parent>(id)` declared on every FK** in the migrator (`flights.aircraft_id`, `waypoints.flight_id`, `orthophotos.flight_id`, `gps_corrections.flight_id` + `gps_corrections.waypoint_id`, `map_objects.flight_id`) |
| S3 | Default values | not detailed | **Migrator sets `DEFAULT 0` / `DEFAULT FALSE` / `DEFAULT NOW()` / `DEFAULT ''`** on most non-nullable columns |
**ACs / NFTs to revise**: data_parameters.md § 3 schema tables (TIMESTAMPTZ → TIMESTAMP, add REFERENCES notes, add DEFAULT notes); AC-2.8 (TOCTOU on FK) — actually now PARTLY mitigated by DB-level FK (insert would fail at DB layer with PG error 23503, not just app-layer); modules/database.md § Internal Logic.
### D-FILTER — Services/AircraftService.cs + FlightService.cs (SMALL)
| # | Aspect | Doc claim (AC-1.6) | Code today |
|---|--------|---------------------|------------|
| F1 | Vehicle (today: Aircraft) `name` filter case sensitivity | "**case-sensitive contains** on `Name`" (AC-1.6, data_parameters.md § 2.1) | **`a.Name.ToLower().Contains(query.Name.ToLower())`** — **case-INSENSITIVE** contains |
| F2 | Mission (today: Flight) `name` filter case sensitivity | not specified in AC-2.3 | **case-INSENSITIVE** contains (same `ToLower().Contains(ToLower())` pattern) |
| F3 | Mission list ordering | AC-2.3 doesn't specify | **`OrderByDescending(f => f.CreatedDate)`** — newest first |
| F4 | Vehicle list ordering | AC-1.5 doesn't specify | **`OrderBy(a => a.Name)`** — alphabetical ASC |
**ACs / NFTs to revise**: AC-1.6 (case-INSENSITIVE); FT-N-01 (current test asserts "case mismatch returns 0 rows" which is WRONG against today's code — case is ignored, so a `name=br` query against `BR-01` actually returns 1 row, not 0); add ordering specs to AC-1.5 and AC-2.3; FT-P-04 + FT-P-08 should assert ordering.
### D-WP-NEST-CHECK — Services/WaypointService.cs (TINY)
| # | Aspect | Doc claim (AC-4.2) | Code today |
|---|--------|---------------------|------------|
| W1 | Parent-mission existence check | "Parent mission missing → 404" | Code's `CreateWaypoint` checks `db.Flights.AnyAsync(f => f.Id == flightId)` and throws `KeyNotFoundException` ✓; `UpdateWaypoint` and `DeleteWaypoint` use a composite WHERE `w.FlightId == flightId && w.Id == waypointId` and throw `KeyNotFoundException` if no match — meaning the test for "parent missing" returns 404 BUT the doc-implied "parent missing first, then waypoint missing" two-step check is collapsed into one. |
**ACs / NFTs to revise**: minor — clarify in AC-4.2 that the check is "matching `(flightId, waypointId)` returns no row → 404", which collapses two error cases into one.
### D-VERIFICATION-LOG — `_docs/02_document/04_verification_log.md` (META)
The verification log itself is wrong:
- § 3 row F5 (JWT validation): says "JwtExtensions matches exactly" — **wrong**, see D-JWT above.
- § 3 row F6 (Startup + migration): says "matches exactly" but the docs claim hardcoded fallbacks while code has `ResolveRequiredOrThrow`**wrong**, see D-CONFIG.
- § 4.1 D6 (modules/middleware.md correction): correctly identifies the camelCase envelope, ✓.
- § 4.2 F3 (carry-forward Swagger / CORS unconditional): "CORS unconditional" is wrong — code is gated. Swagger is still unconditional ✓.
**Action**: re-issue § 3 rows F5, F6 with the new evidence; demote § 4.2 F3 (CORS unconditional) into the corrected list.
---
## Recommended re-verification + revision plan (next session)
### Phase 1 — `/document` re-run in `task` mode, scope = drifted files
Inputs: this drift findings report.
Skills: `.cursor/skills/document/SKILL.md` in **Task mode**.
Files to update (estimate 1012 doc files):
| File | Sections to revise |
|------|---------------------|
| `_docs/02_document/architecture.md` | § 7 Cross-cutting (auth subsection: ECDSA+JWKS+iss/aud), § 7 (CORS subsection: gated), ADR-005 (mark obsolete or rewrite "no dev fallback" + "Swagger still ungated"), ADR-002 (no change — wire shape unaffected) |
| `_docs/02_document/components/05_identity/description.md` | full rewrite of "Mechanism" + "Caveats" (ECDSA, JWKS, iss/aud, calls admin) |
| `_docs/02_document/components/07_host/description.md` | Program.cs section (ConfigurationResolver, CorsConfigurationValidator); ADR-005 cross-ref |
| `_docs/02_document/modules/auth.md` | full rewrite |
| `_docs/02_document/modules/program.md` | rewrite startup section: env var contract, no fallback, CORS gating |
| `_docs/02_document/modules/database.md` | TIMESTAMP (not TIMESTAMPTZ), REFERENCES declared, DEFAULT clauses |
| `_docs/02_document/data_model.md` | § 11 schema table column types + FK note |
| `_docs/02_document/04_verification_log.md` | re-issue § 3 F5+F6 rows; correct § 4.2 F3 |
| `_docs/02_document/state.json` | append `decomposition_revised` entry recording the verification re-run; update `last_updated` |
| `_docs/00_problem/problem.md` | review for any auth-shape claims |
| `_docs/00_problem/acceptance_criteria.md` | AC-1.6 (case), AC-1.5 + AC-2.3 (ordering), AC-5.15.7, AC-5.9, AC-9.1 (today both `FL`+`GPS`), AC-6.1, AC-6.2 |
| `_docs/00_problem/restrictions.md` | E1 (4 env vars), E3 (no fallback today), E4 (no shared secret), E9 (gated CORS), S6 (Swagger still ungated ✓ — no change) |
| `_docs/00_problem/security_approach.md` | JWT validation, CORS gating, no dev secret |
| `_docs/00_problem/input_data/data_parameters.md` | § 1 env vars (4 vars now), § 3 schema (TIMESTAMP, REFERENCES, DEFAULT) |
| `_docs/01_solution/solution.md` | per-component table for 05 + 07 |
### Phase 2 — `/test-spec` re-issue in scoped mode
Inputs: revised docs from Phase 1.
Skill: `.cursor/skills/test-spec/SKILL.md` in **cycle-update mode** (NOT full re-run; scoped to AC-5, AC-6.1/6.2, AC-9.1, AC-1.5, AC-1.6, AC-2.3, E1, E3, E4, E9 + the 4 NFT families that those ACs feed).
Files to revise:
| File | Scope |
|------|-------|
| `_docs/00_problem/input_data/expected_results/results_report.md` | re-issue AC-1 row 1.6, AC-5 entire group, AC-6 rows 6.16.2, AC-9 row 9.1, AC-1 ordering rows, AC-2 ordering rows; add E3+E9 lock rows |
| `_docs/02_document/tests/environment.md` | replace "in-process JWT mint with HS256 shared secret" with **"in-process ECDSA keypair + ephemeral JWKS HTTP service mock"** (e.g. WireMock.NET serves `/.well-known/jwks.json`); add `JWT_ISSUER` + `JWT_AUDIENCE` + `JWT_JWKS_URL` env vars; remove `JWT_SECRET` |
| `_docs/02_document/tests/test-data.md` | rewrite "External Dependency Mocks" — `admin` JWKS mock; rewrite Data Validation Rules JWT rows |
| `_docs/02_document/tests/blackbox-tests.md` | revise FT-N-01 (case-insensitive); add ordering assertions to FT-P-04 + FT-P-08 |
| `_docs/02_document/tests/security-tests.md` | full revision of NFT-SEC-01 through NFT-SEC-09 (ECDSA, iss/aud, JWKS rotation, missing JWT_ISSUER startup throw) |
| `_docs/02_document/tests/resilience-tests.md` | revise NFT-RES-05 (`ResolveRequiredOrThrow` failure modes — add scenarios for missing each of the 4 env vars); revise NFT-RES-07 (JWKS rotation, not shared-secret rotation) |
| `_docs/02_document/tests/traceability-matrix.md` | re-trace AC-5, AC-6, AC-9, AC-1.5, AC-1.6, AC-2.3, E-rows |
| `docker-compose.test.yml` | replace `JWT_SECRET` with `JWT_ISSUER` + `JWT_AUDIENCE` + `JWT_JWKS_URL`; add a `jwks-mock` service (e.g. WireMock or a small Kestrel test server) |
### Phase 3 — Resume autodev Step 4 (Code Testability Revision)
After Phase 1+2: re-enter `existing-code` Step 4 with the revised docs + test specs. The original Step 4 analysis result ("code is largely testable as-is") still holds — the JWT/CORS/Config drift didn't introduce hardcoded paths or singletons, it just made the code MORE testable than the docs described.
Expected outcome: Step 4 → "all scenarios testable as-is" → Step 5 (Decompose Tests, **session boundary**).
---
## Cross-cutting acknowledgements
- The B-ticket plan (B5B12) is unaffected. None of the drift overlaps with the rename/GPS-Denied work — the JWT/CORS/Config evolution happened independently.
- The `_docs/_process_leftovers/2026-05-14_rename-flights-to-missions.md` leftover stays as-is.
- The suite docs (`../suite/_docs/00_roles_permissions.md`, `../suite/_docs/05_identity*`, etc.) likely have correlated drift on the JWT model. Out of scope for this repo's `/autodev`; flag at suite-level next time `/autodev` runs in the suite workspace.
+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.
+368
View File
@@ -0,0 +1,368 @@
# 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 using ECDSA-SHA256 and validated locally against `admin`'s JWKS, which this service fetches once at startup and caches; request-path validation is local and does not call `admin`. 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`; ECDSA-SHA256 validation against admin's JWKS (cached locally); 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 ECDSA-SHA256 against admin's JWKS (cached); `iss` and `aud` both validated; `alg` pinned to `EcdsaSha256` (defends against HS256-confusion). The CMMC L2 finding tracked under AZ-487 / AZ-494 is now structurally addressed in this service's code; the suite-level docs still describe the legacy HS256 model and have a sync task pending.
- **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 against admin's public JWKS** (ECDSA-SHA256). The JWKS is fetched once at startup (via `Microsoft.IdentityModel.Protocols.ConfigurationManager<JsonWebKeySet>`) and refreshed on the default schedule; per-request validation is local. *[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 NOT gated on `IsDevelopment()` (ADR-005, scope reduced — the "dev fallback secrets" aspect is now obsolete; see ADR-005 below for details).
- `"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) | JWKS over HTTPS (outbound, startup + refresh) + JWT validation (inbound) | Outbound at startup; inbound on every request | Issues ECDSA-signed bearer tokens; this service fetches admin's public JWKS once at startup, caches it, and validates tokens locally thereafter. No per-request callback. JWKS rotation does not require a coordinated redeploy |
| 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` + `Microsoft.IdentityModel.Protocols` | 10.0.5 | JWT bearer with ECDSA-SHA256 against admin's JWKS (cached via `ConfigurationManager<JsonWebKeySet>`); `iss`/`aud` validated; algorithm pinned |
| 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` | Operator-supplied env var or `Database:Url` config key (e.g. `Host=localhost;Database=azaion;Username=postgres;Password=changeme`). **No hardcoded fallback**`ConfigurationResolver.ResolveRequiredOrThrow` aborts startup if unset | `postgresql://postgres:${PG_LOCAL_PASSWORD}@postgres-local/azaion` (compose env) |
| `JWT_ISSUER` | Operator-supplied (e.g. `https://admin.azaion.dev/`). **Required at startup** | Set by Edge compose to the central admin issuer |
| `JWT_AUDIENCE` | Operator-supplied (e.g. `missions`). **Required at startup** | Set by Edge compose to this service's audience identifier |
| `JWT_JWKS_URL` | Operator-supplied HTTPS URL (e.g. `https://admin.azaion.dev/.well-known/jwks.json`). **Required at startup** + must be HTTPS (`HttpDocumentRetriever.RequireHttps = true`) | Set by Edge compose to admin's JWKS endpoint |
| `CorsConfig:AllowedOrigins` | Optional; defaults to `[]` (implicit-permissive policy + startup warning) | Required when `CorsConfig:AllowAnyOrigin != true` — startup THROWS in `Production` with empty origins |
| `CorsConfig:AllowAnyOrigin` | Optional; defaults to `false` | Optional; explicit opt-in if reverse-proxy already enforces origin checks |
| Logging | Console / Debug (ASP.NET Core defaults) + `PermissiveDefaultWarning` when implicit-permissive CORS applies | Console only (no Serilog / structured logging configured today) |
| Swagger | enabled | enabled (NOT gated on `IsDevelopment()` — see ADR-005) |
| CORS | Permissive fallback (with `PermissiveDefaultWarning` startup log) | Explicit allow-list via `CorsConfig:AllowedOrigins`, or explicit `AllowAnyOrigin=true` if reverse-proxy gates origins; implicit-permissive aborts startup |
| 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 with **ECDSA-SHA256** signature validation. Tokens are minted by the central `admin` service (which holds the ECDSA private key) and validated locally by `05_identity` against admin's public JWKS document. The JWKS is fetched once at startup via `Microsoft.IdentityModel.Protocols.ConfigurationManager<JsonWebKeySet>` against `JWT_JWKS_URL` (HTTPS only — `HttpDocumentRetriever.RequireHttps = true`) and refreshed on the manager's default schedule. After the initial fetch, request-path validation is local; no per-request callback to `admin`. Validation enforces `iss == JWT_ISSUER`, `aud == JWT_AUDIENCE`, `exp` (with 30-second clock skew), and pins `alg` to `EcdsaSha256` to defend against the HS256-confusion attack. **JWKS rotation does NOT require a coordinated redeploy** — consumers pick up new keys on the next refresh tick, and old tokens signed with the previous `kid` remain valid until their natural expiry. The CMMC L2 finding (`../../suite/_docs/05_security/cmmc_l2_scorecard.md` row 3) about missing `iss`/`aud` validation is structurally fixed in this service's code; the suite-level docs still describe the legacy HS256 model and have a sync task pending (drift recorded in `_docs/02_document/05_drift_findings_2026-05-14.md`).
**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. The JWKS fetch is independently constrained to HTTPS by `HttpDocumentRetriever { RequireHttps = true }`.
- **Secrets management**: Four required env vars (`DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL`) plus optional CORS keys flow through `Infrastructure/ConfigurationResolver.cs``ResolveRequiredOrThrow`. **There are no hardcoded fallbacks**; a missing required value aborts startup with `InvalidOperationException` before the host is built. A production deploy that forgets `JWT_JWKS_URL` cannot silently accept tokens — it fails fast. The legacy `JWT_SECRET` env var is no longer consulted.
**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, plus `Program.cs`' `PermissiveDefaultWarning` when implicit-permissive CORS applies. 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**: Gated by `Infrastructure/CorsConfigurationValidator.cs`. In `Production` (case-insensitive match on `ASPNETCORE_ENVIRONMENT`) an empty `CorsConfig:AllowedOrigins` with `CorsConfig:AllowAnyOrigin != true` aborts startup. In non-Production environments, an empty allow-list with `AllowAnyOrigin=false` falls back to permissive (`AllowAnyOrigin/Method/Header`) and emits the `PermissiveDefaultWarning` startup log. Explicit `AllowAnyOrigin=true` always applies permissive without warning. The previous "permissive in all environments" model no longer holds.
## 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 NOT gated on `IsDevelopment()` (scope reduced — dev-fallback secrets obsoleted)
**Context**: ASP.NET Core's idiomatic pattern gates Swagger UI and dev-only convenience features on `app.Environment.IsDevelopment()`. The original form of this ADR also covered hardcoded dev fallbacks for `JWT_SECRET` / `DATABASE_URL`; that aspect is now obsolete after the introduction of `Infrastructure/ConfigurationResolver.cs` (fail-fast `ResolveRequiredOrThrow`). The only remaining gap is Swagger.
**Decision (current, carry-forward)**: Leave Swagger UI mounted unconditionally. Swagger UI is useful on edge devices for one-off operator debugging through the local network. There is no hardcoded dev fallback for any secret today.
**Alternatives considered**:
1. **Gate Swagger on `IsDevelopment()` (or on `ASPNETCORE_ENVIRONMENT != "Production"`)** — preferred long-term; out of this Epic.
2. **Add a Swagger security scheme so the UI knows how to attach `Authorization: Bearer ...`** — usability improvement; 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.
- The "production silently boots with the dev secret" risk no longer exists: `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL`, and `DATABASE_URL` are all required, and `ResolveRequiredOrThrow` aborts startup with `InvalidOperationException` if any is missing. The CMMC L2 row-3 finding (HS256 + missing `iss`/`aud`) is also structurally addressed by the ECDSA + JWKS + iss/aud-validation model — see Section 7 above.
### 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,139 @@
# 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 in the post-rename target scope.
> **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`, `Infrastructure/ConfigurationResolver.cs` (consumed for fail-fast value resolution)
## 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. JWT signature validation is **asymmetric (ECDSA-SHA256)** against public keys retrieved from `admin`'s JWKS endpoint and cached locally; `admin` is **not** contacted on the request path after the first JWKS fetch.
**Upstream dependencies**: `Infrastructure/ConfigurationResolver.cs` (shared with `07_host`) for fail-fast value resolution.
**Downstream consumers**: `07_host` (calls `AddJwtAuth(builder.Configuration)` once); `01_vehicle_catalog`, `02_mission_planning` (controllers carry `[Authorize(Policy = "FL")]`).
## 2. Internal Interface
```csharp
public static IServiceCollection AddJwtAuth(this IServiceCollection services, IConfiguration configuration);
```
`AddJwtAuth` reads three required values via `ConfigurationResolver.ResolveRequiredOrThrow`:
| Env var | Config key | Purpose |
|---------|------------|---------|
| `JWT_ISSUER` | `Jwt:Issuer` | Expected `iss` claim value |
| `JWT_AUDIENCE` | `Jwt:Audience` | Expected `aud` claim value |
| `JWT_JWKS_URL` | `Jwt:JwksUrl` | HTTPS URL of admin's JWKS document (e.g. `https://admin.azaion/.well-known/jwks.json`) |
Each value is resolved env-var-first, then config-key, then throws `InvalidOperationException` at startup. There is **no dev fallback**. The legacy `JWT_SECRET` env var is no longer consulted.
Side effects: registers `JwtBearerDefaults.AuthenticationScheme` and **two** named authorization policies in DI (one is removed after B7 lands):
| Policy | Requirement | Notes |
|--------|-------------|-------|
| `"FL"` | JWT contains a `permissions` claim with value `"FL"` | Permanent |
| `"GPS"` | JWT contains a `permissions` claim with value `"GPS"` | Removed in Jira B7 (legacy GPS-Denied routes are moving out of this repo) |
## 3. JWT model (this service) vs. suite-wide pattern
**This service's implementation** is described in code below. The suite-wide pattern lives in `../../../suite/_docs/00_top_level_architecture.md` and `../../../suite/_docs/10_auth.md` — those documents currently describe the legacy HS256 / shared-secret model and have **not yet been updated** to reflect the ECDSA-on-JWKS evolution captured here. The drift between this service and the suite docs is flagged in `_docs/02_document/05_drift_findings_2026-05-14.md` and will be picked up at the suite level on the next suite `/autodev` invocation. The remaining .NET consumers (`annotations`, `satellite-provider`) may or may not have made the same transition; their docs are the source of truth for their own implementation.
What is verified against `Auth/JwtExtensions.cs` today:
```
┌─────────────────────┐ ┌──────────────────────┐
│ Operator UI │ POST /login │ admin (.NET, remote) │
│ (React, edge) │ ──────────────► │ central user DB │
│ │ ◄────────────── │ ECDSA-signs JWT, │
│ │ Bearer JWT │ exposes JWKS │
└──────────┬──────────┘ └──────┬───────────────┘
│ Bearer JWT │
│ │ /.well-known/jwks.json
│ │ (HTTPS, fetched once at startup,
│ │ cached by ConfigurationManager,
│ │ refreshed on default schedule)
└────────────► missions ◄───────────┘
(this service)
validates: ECDSA-SHA256 signature,
iss = JWT_ISSUER,
aud = JWT_AUDIENCE,
exp (with 30s clock skew),
alg pinned to EcdsaSha256
```
`admin` holds the **private** ECDSA key and signs tokens. This service fetches the **public** JWKS document from `admin` once at startup (on the first protected request after process start) and caches it. Request-path validation is purely cryptographic against the cached keys; `admin` is not contacted per request. The user logs in once at the UI; the resulting bearer token is reusable across every backend service for its lifetime.
The `permissions` claim drives per-service `[Authorize(Policy = "...")]` checks. The role → permission matrix lives in `../../../suite/_docs/00_roles_permissions.md`. All routes in `01_vehicle_catalog` and `02_mission_planning` require `FL`.
## 4. External API
None directly. Auth contract is observable only via `401 Unauthorized` / `403 Forbidden` on protected routes, plus the HTTPS JWKS fetch to `admin` at startup (out-of-band).
## 5. Data Access Patterns
None against the local PostgreSQL. One outbound HTTPS GET to the configured `JWT_JWKS_URL` at process start, cached by `ConfigurationManager<JsonWebKeySet>` and refreshed on its default schedule (matches admin's `Cache-Control: public, max-age=3600` on the JWKS endpoint).
## 6. Implementation Details
**Mechanism**: ECDSA-SHA256 signature validation against public keys retrieved from `admin`'s JWKS endpoint. The keys are wrapped in a `ConfigurationManager<JsonWebKeySet>` configured with:
- `jwksUrl` — resolved at startup from `JWT_JWKS_URL` / `Jwt:JwksUrl` (fail-fast if missing).
- A custom `JwksRetriever : IConfigurationRetriever<JsonWebKeySet>` (private nested class in `JwtExtensions.cs`) that wraps an `IDocumentRetriever` and parses the response as a `JsonWebKeySet`. The stock `OpenIdConnectConfigurationRetriever` targets the full OIDC discovery document, which `admin` does not publish — only the JWKS endpoint is exposed — so the minimal retriever is used.
- `HttpDocumentRetriever { RequireHttps = true }` — non-HTTPS JWKS URLs are rejected at configuration time.
**Token validation parameters** (`TokenValidationParameters`):
| Parameter | Value |
|-----------|-------|
| `ValidateIssuer` | `true` |
| `ValidIssuer` | `<resolved JWT_ISSUER>` |
| `ValidateAudience` | `true` |
| `ValidAudience` | `<resolved JWT_AUDIENCE>` |
| `ValidateLifetime` | `true` |
| `ValidateIssuerSigningKey` | `true` |
| `ValidAlgorithms` | `[SecurityAlgorithms.EcdsaSha256]` |
| `RequireSignedTokens` | `true` |
| `RequireExpirationTime` | `true` |
| `ClockSkew` | `TimeSpan.FromSeconds(30)` |
| `IssuerSigningKeyResolver` | Delegate that fetches the cached `JsonWebKeySet` and returns the matching `kid`'s keys (or all keys if `kid` is empty) |
**Key Dependencies**:
| Library | Version | Purpose |
|---------|---------|---------|
| `Microsoft.AspNetCore.Authentication.JwtBearer` | 10.0.5 | JWT bearer middleware + handler |
| `Microsoft.IdentityModel.Protocols` | (transitive) | `ConfigurationManager<T>`, `HttpDocumentRetriever`, `IConfigurationRetriever<T>`, `IDocumentRetriever` |
| `Microsoft.IdentityModel.Tokens` | (transitive) | `JsonWebKeySet`, `TokenValidationParameters`, `SecurityAlgorithms` |
## 7. Extensions and Helpers
- `JwksRetriever` — private nested class in `JwtExtensions.cs`. Minimal `IConfigurationRetriever<JsonWebKeySet>` implementation; ~5 lines. Exists because Microsoft does not ship a JWKS-only retriever.
## 8. Caveats & Edge Cases
1. **`admin` reachability at startup** — the first protected request blocks on the JWKS fetch. If `admin` is unreachable when that fetch happens, the request fails with a 500 (the `IssuerSigningKeyResolver` delegate throws while resolving signing keys). On the local LAN this is single-digit ms typical. Once cached, subsequent requests do not call `admin`.
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. **Fail-fast on missing configuration**: `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL` are all required at startup. A production deploy without any of them throws `InvalidOperationException` from `ConfigurationResolver.ResolveRequiredOrThrow` before the host is built. There is **no hardcoded fallback** (ADR-005's "dev-fallback secret" branch is obsolete for JWT).
5. **JWKS rotation does NOT require a coordinated redeploy** — when `admin` rotates keys, the next refresh tick on every consumer's `ConfigurationManager` picks up the new public key. Old tokens signed by the previous key remain valid until expiry as long as the old `kid` is still published. This is the major operational improvement over the legacy HS256 shared-secret model.
6. **Algorithm pin (`ValidAlgorithms = [EcdsaSha256]`)** prevents the classic "HS256 confusion" attack — without the pin, an attacker who learned the JWKS public key could forge `alg: HS256` tokens using the public key as the HMAC secret. The pin forces ECDSA regardless of the token header's `alg` claim.
7. **`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**: `Infrastructure/ConfigurationResolver.cs` (the fail-fast resolver — shared with `07_host`).
**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. The custom `JwksRetriever` does not emit logs of its own; the `ConfigurationManager<JsonWebKeySet>` may log refresh failures at Warning per its built-in instrumentation.
@@ -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,96 @@
# 07 — Host (Composition Root)
**Spec source**: `../../../suite/_docs/00_top_level_architecture.md` § Edge compose excerpt — confirms the port (`5002:8080`) and DB target (`postgres-local`). **The env-var contract in suite docs still references the legacy `JWT_SECRET`** and predates this service's transition to JWKS-based JWT validation; the four-variable env contract documented below is the verified current state in code, and the suite docs are flagged for sync in `_docs/02_document/05_drift_findings_2026-05-14.md`.
**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`, `Infrastructure/ConfigurationResolver.cs`, `Infrastructure/CorsConfigurationValidator.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**: All required values flow through `Infrastructure/ConfigurationResolver.cs``ResolveRequiredOrThrow`. Resolution order per value: `Environment.GetEnvironmentVariable(envVar)``IConfiguration[configKey]` → throws `InvalidOperationException` at startup with a message naming both the env var and the config key. There are **no hardcoded dev fallbacks**; a misconfigured production deploy cannot silently boot.
| Env var | Config key | Required? | Purpose |
|---------|------------|-----------|---------|
| `DATABASE_URL` | `Database:Url` | **Yes** | Either Npgsql key=value form OR a `postgresql://` URI (converted via `ConvertPostgresUrl`) |
| `JWT_ISSUER` | `Jwt:Issuer` | **Yes** | Expected `iss` claim value (see `05_identity`) |
| `JWT_AUDIENCE` | `Jwt:Audience` | **Yes** | Expected `aud` claim value (see `05_identity`) |
| `JWT_JWKS_URL` | `Jwt:JwksUrl` | **Yes** | HTTPS URL of admin's JWKS endpoint (see `05_identity`) |
| `CorsConfig:AllowedOrigins` | (same) | No (defaults to `[]`) | String array of allowed origins for the CORS policy |
| `CorsConfig:AllowAnyOrigin` | (same) | No (defaults to `false`) | When `true`, applies `AllowAnyOrigin/Method/Header` regardless of origins |
The legacy `JWT_SECRET` env var is **no longer consulted**; `05_identity` documents the JWKS-based replacement.
**CORS gating** (`Infrastructure/CorsConfigurationValidator.cs`):
- `EnsureSafeForEnvironment(origins, allowAnyOrigin, environmentName)` THROWS `InvalidOperationException` in `Production` (case-insensitive match on the `ASPNETCORE_ENVIRONMENT` value) when origins are empty AND `allowAnyOrigin` is `false`. The host refuses to start with an implicit permissive policy in Production.
- `ShouldUsePermissivePolicy(origins, allowAnyOrigin)` returns `true` when `allowAnyOrigin == true` OR origins is empty — used by the CORS policy builder. In non-Production environments with empty origins this falls back to permissive.
- `ShouldWarnAboutPermissiveDefault(origins, allowAnyOrigin)` is `true` when origins are empty AND `allowAnyOrigin` is `false` (implicit permissive). When true, the host logs `PermissiveDefaultWarning` at startup with the current environment name.
**`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
- **Swagger is unconditional**: Swagger UI + JSON spec are mounted regardless of environment (no `IsDevelopment()` guard). This is the **only remaining** aspect of ADR-005 that still applies — the legacy "dev-fallback secret" aspect of ADR-005 is now obsolete (`ConfigurationResolver.ResolveRequiredOrThrow` throws on any missing value at startup).
- **CORS hard-fail is `Production`-only**. In `Staging` or any custom environment name that is not literal `Production` (case-insensitive), an empty allow-list with `AllowAnyOrigin=false` falls back to permissive (with a startup warning) instead of throwing. Operators deploying to a "Staging" tier that should be locked down need to set `CorsConfig:AllowedOrigins` explicitly — the validator will not enforce it for them.
- **JWKS startup dependency**: the first protected request after process start triggers a synchronous HTTPS fetch to `JWT_JWKS_URL`. If `admin` is unreachable at that moment, the request fails 500 from `05_identity`'s `IssuerSigningKeyResolver`. Once cached, request-path validation does not call `admin`.
- **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). Note: `JWT_JWKS_URL` is independently constrained to HTTPS by `HttpDocumentRetriever { RequireHttps = true }` inside `05_identity`.
- **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.
+234
View File
@@ -0,0 +1,234 @@
# 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 "PG TIMESTAMP (no TZ), DEFAULT NOW()"
text name
uuid vehicle_id FK "REFERENCES vehicles(id), NO ACTION on delete"
}
WAYPOINT {
uuid id PK
uuid mission_id FK "REFERENCES missions(id), NO ACTION on delete"
decimal lat "nullable"
decimal lon "nullable"
text mgrs "nullable"
int waypoint_source "WaypointSource enum, DEFAULT 0"
int waypoint_objective "WaypointObjective enum, DEFAULT 0"
int order_num "DEFAULT 0"
decimal height "DEFAULT 0"
}
MAP_OBJECT {
uuid id PK
uuid mission_id FK "REFERENCES missions(id), NO ACTION on delete"
text h3_index "Uber H3 hex grid"
text mgrs
decimal lat "nullable"
decimal lon "nullable"
int class_num "DEFAULT 0"
text label "DEFAULT ''"
decimal size_width_m "DEFAULT 0"
decimal size_length_m "DEFAULT 0"
decimal confidence "DEFAULT 0"
int object_status "ObjectStatus enum, DEFAULT 0"
timestamp first_seen_at "PG TIMESTAMP (no TZ), DEFAULT NOW()"
timestamp last_seen_at "PG TIMESTAMP (no TZ), DEFAULT NOW()"
}
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 (`REFERENCES vehicles(id)` declared in the migrator) + by `MissionService` existence check at create / update. The two together close the TOCTOU gap (FK rejects insert with PostgreSQL error `23503` 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 and the AC-2.8 entry in `00_problem/acceptance_criteria.md`).
- **`waypoint.mission_id` MUST reference an existing `mission.id`** — enforced by FK + by `WaypointService` existence check at create. The composite WHERE on update/delete (`w.MissionId == missionId && w.Id == waypointId`) collapses "parent missing" and "child missing" into a single 404 — see `service_waypoint.md` Caveats #2.
- **`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.
- **All FK columns have `REFERENCES` declared in the migrator** (no `ON DELETE` clause; PostgreSQL defaults to `NO ACTION`). The in-code cascade walks in `MissionService.DeleteMission` and `WaypointService.DeleteWaypoint` delete child rows before parent rows — see `architecture.md` ADR-003 for why the cascade lives in code instead of `ON DELETE CASCADE`.
- **All timestamp columns use PostgreSQL `TIMESTAMP`** (no timezone): `missions.created_date`, `map_objects.first_seen_at`, `map_objects.last_seen_at`. `DateTime.Kind` round-trips as `Unspecified` from the database; the application writes `DateTime.UtcNow` and treats values as UTC by convention.
### 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,134 @@
# Flow F5 — JWT bearer validation
> Cross-cutting flow that runs on every `[Authorize]` request. **ECDSA-SHA256 asymmetric validation against public keys cached from `admin`'s JWKS endpoint.** `admin` is contacted once at startup (and on JWKS refresh) for the JWKS document; subsequent request-path validation is local and does not call `admin`.
## Description
ASP.NET Core's `JwtBearerHandler` validates incoming `Authorization: Bearer <jwt>` headers using public ECDSA keys cached locally from `admin`'s JWKS endpoint. On success, the request continues to the controller with a `ClaimsPrincipal` attached. On signature / lifetime / `iss` / `aud` / `alg` failure → `401`. On valid token but missing required permission claim → `403`. Both `iss` and `aud` are validated against the resolved `JWT_ISSUER` / `JWT_AUDIENCE` values; the signing algorithm is pinned to `EcdsaSha256` (see `05_identity` § Implementation Details for the rationale).
## Preconditions
- `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL` are all resolved at startup via `ConfigurationResolver.ResolveRequiredOrThrow`. Any missing value aborts startup before the host is built.
- `AddJwtAuth(builder.Configuration)` was called during `Program.cs` startup (F6); this also wired the `ConfigurationManager<JsonWebKeySet>` against the resolved JWKS URL.
- For the **first** protected request after process start, the cached JWKS is empty; the `IssuerSigningKeyResolver` synchronously fetches it from `admin`. After that fetch, subsequent requests use the cached keys until the manager's next refresh tick.
## Sequence Diagram
```mermaid
sequenceDiagram
autonumber
participant Client as UI / Operator API client
participant Pipeline as ASP.NET Pipeline
participant Handler as JwtBearerHandler
participant Resolver as IssuerSigningKeyResolver
participant Mgr as ConfigurationManager<JsonWebKeySet>
participant Admin as admin (JWKS endpoint)
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 header — check alg ∈ ValidAlgorithms (EcdsaSha256)
alt alg not in pin list
Handler-->>Client: 401 Unauthorized (algorithm rejected)
else alg OK
Handler->>Resolver: resolve signing key for kid
Resolver->>Mgr: GetConfigurationAsync(...).GetAwaiter().GetResult()
alt JWKS not cached yet
Mgr->>Admin: GET /.well-known/jwks.json (HTTPS, RequireHttps=true)
Admin-->>Mgr: JsonWebKeySet
Mgr->>Mgr: cache JWKS, schedule next refresh
else JWKS cached
Mgr-->>Resolver: cached JsonWebKeySet
end
Resolver-->>Handler: signing keys matching kid (or all keys if kid empty)
Handler->>Handler: verify ECDSA-SHA256 signature
alt Signature invalid
Handler-->>Client: 401 Unauthorized
else Signature valid
Handler->>Handler: validate iss == JWT_ISSUER, aud == JWT_AUDIENCE, exp (ClockSkew = 30s)
alt iss/aud mismatch OR token expired
Handler-->>Client: 401 Unauthorized
else Claims OK
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
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| AlgPin{alg in ValidAlgorithms — EcdsaSha256?}
AlgPin -->|no| UnauthAlg([401 Unauthorized — algorithm rejected])
AlgPin -->|yes| Cache{JWKS cached?}
Cache -->|no| Fetch[HTTPS GET JWT_JWKS_URL → cache]
Cache -->|yes| Sig{ECDSA-SHA256 signature valid for kid?}
Fetch --> Sig
Sig -->|no| Unauth2([401 Unauthorized])
Sig -->|yes| Iss{iss == JWT_ISSUER?}
Iss -->|no| UnauthIss([401 Unauthorized — issuer mismatch])
Iss -->|yes| Aud{aud == JWT_AUDIENCE?}
Aud -->|no| UnauthAud([401 Unauthorized — audience mismatch])
Aud -->|yes| Life{Lifetime valid? ClockSkew=30s}
Life -->|no| Unauth3([401 Unauthorized — expired])
Life -->|yes| BuildPrincipal[Build ClaimsPrincipal]
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` | `ConfigurationManager` | request for cached `JsonWebKeySet` (or refresh) | in-process |
| 3 | `HttpDocumentRetriever` (on cold cache) | `admin` | `GET /.well-known/jwks.json` over HTTPS | HTTP |
| 4 | `admin` | `HttpDocumentRetriever` | JWKS JSON document | application/json |
| 5 | `JwtBearerHandler` | (in-process) | parsed JWT (header, payload, signature) | JSON Web Token |
| 6 | `JwtBearerHandler` | (in-process) | `ClaimsPrincipal` (with `iss`, `aud`, `permissions`, …) | .NET principal object |
| 7 | Authorization policy evaluator | Controller | "policy satisfied" / `403` | flag |
| 8 | `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` |
| Token header `alg` not in `[EcdsaSha256]` (e.g. forged `alg: HS256`) | `JwtBearerHandler` | Algorithm pin check | `401`. Pin defends against the HS256-confusion attack — see `05_identity` Caveats #6 |
| Signature mismatch (wrong key, key not yet published, key rotated out) | `JwtBearerHandler` | ECDSA verify fails | `401`. Recovery: ensure `admin` published the corresponding `kid` in JWKS; on rotation the cache picks up the new keys at the next refresh tick |
| Signing `kid` not in cached JWKS | `IssuerSigningKeyResolver` | No matching key in current cache | `401`. The manager refreshes on its default schedule; a new `kid` becomes available there |
| `iss` claim ≠ `JWT_ISSUER` | `JwtBearerHandler` | `ValidateIssuer = true` | `401`. Tokens issued by a different `iss` (e.g. another suite environment) are rejected |
| `aud` claim ≠ `JWT_AUDIENCE` | `JwtBearerHandler` | `ValidateAudience = true` | `401`. Tokens minted for a different audience (e.g. `admin` itself, or another backend) are rejected |
| Expired token | `JwtBearerHandler` | `ValidateLifetime = true` (`ClockSkew = 30s`) | `401`. Tight 30-second skew — caller may experience earlier expiration than under the .NET default of 5 minutes (or the prior 1-minute setting) |
| `permissions` claim missing or wrong value | Policy `"FL"` evaluator | claim lookup | `403` |
| `admin` unreachable on first JWKS fetch | `HttpDocumentRetriever` | `HttpRequestException` propagated through `IssuerSigningKeyResolver` | First protected request fails 500 (handler exception → `06_http_conventions` global handler). Subsequent requests retry on the next refresh tick. **Operationally**: ensure `admin` is reachable from every edge device that authenticates against it |
| `JWT_JWKS_URL` is plain HTTP | Startup (`HttpDocumentRetriever { RequireHttps = true }`) | URL scheme check at retrieve time | Service fails to validate any request; symptom is `InvalidOperationException` on JWKS fetch. **Fix**: set `JWT_JWKS_URL` to an `https://` URL |
## Performance Expectations
| Metric | Target | Notes |
|--------|--------|-------|
| Validation latency (warm cache) | sub-millisecond typical | Pure ECDSA verify + claim lookup; no I/O |
| Validation latency (cold cache, first request) | one-time JWKS fetch cost (single-digit ms on local LAN) | Synchronous `GetAwaiter().GetResult()` blocks the worker thread until the fetch returns |
| Throughput | bounded by request throughput | No back-pressure; the cached JWKS handles all subsequent requests until refresh |
| JWKS refresh frequency | `ConfigurationManager` default (5 minutes minimum) | Matches admin's `Cache-Control: public, max-age=3600` so a forced refresh always sees fresh content |
## Notes on key rotation
Unlike the legacy shared-secret model, JWKS rotation does **NOT** require a coordinated redeploy of every consumer. When `admin` rotates keys it publishes the new key alongside the old `kid` (or with a new `kid`). The next refresh tick on every consumer's `ConfigurationManager` picks up the new public key. Old tokens signed under the previous `kid` remain valid until expiry as long as the old `kid` is still published. This is a major operational improvement over the previous "rotate `JWT_SECRET`, re-deploy every backend, force every user to re-login" sequence.
@@ -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,109 @@
# 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`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL` all resolve via `ConfigurationResolver.ResolveRequiredOrThrow`. Any missing value aborts startup before the host is built — there are no hardcoded fallbacks.
- `postgres-local` is reachable.
- The `azaion` database itself exists in PostgreSQL (created at provisioning time, NOT by this migrator).
- `admin` does NOT need to be reachable at process start. The JWKS fetch is lazy — it happens on the first protected request, not during the startup sequence diagrammed below.
## 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: ResolveRequiredOrThrow DATABASE_URL → ConvertPostgresUrl → Npgsql connection string
Host->>Cfg: ResolveRequiredOrThrow JWT_ISSUER, JWT_AUDIENCE, JWT_JWKS_URL — throw on any miss
Host->>Identity: AddJwtAuth(builder.Configuration) — DI registration + ConfigurationManager<JsonWebKeySet> wiring (no network yet)
Host->>Cfg: read CorsConfig:AllowedOrigins, CorsConfig:AllowAnyOrigin
Host->>Cfg: CorsConfigurationValidator.EnsureSafeForEnvironment — throws in Production when origins=[] AND allowAnyOrigin=false
Host->>DI: register CORS policy (permissive OR WithOrigins(...))
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: emit PermissiveDefaultWarning if implicit-permissive CORS applies (non-Production with empty origins)
Host->>Host: register ErrorHandlingMiddleware FIRST in pipeline
Host->>Host: UseCors / 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]) --> ResolveDB[ResolveRequiredOrThrow DATABASE_URL]
ResolveDB --> ResolveJwt["ResolveRequiredOrThrow JWT_ISSUER, JWT_AUDIENCE, JWT_JWKS_URL"]
ResolveJwt --> CorsCfg[Read CorsConfig:AllowedOrigins + CorsConfig:AllowAnyOrigin]
CorsCfg --> CorsGate{Production AND origins=[] AND allowAnyOrigin=false?}
CorsGate -->|yes| FailFast([InvalidOperationException — Watchtower restarts])
CorsGate -->|no| RegDI[DI registrations: JWT bearer + JWKS manager, CORS, controllers, scoped DB + services]
RegDI --> Build[Build Host]
Build --> CorsWarn{Implicit-permissive CORS in this env?}
CorsWarn -->|yes| LogWarn[Log PermissiveDefaultWarning]
CorsWarn -->|no| OpenScope[Open startup scope]
LogWarn --> OpenScope
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])
ResolveDB -. on missing value .-> FailFast
ResolveJwt -. on missing value .-> FailFast
Migrate -. on failure .-> Crash([Process exits non-zero — Watchtower restarts])
```
## Data Flow
| Step | From | To | Data | Format |
|------|------|----|------|--------|
| 1 | Environment / IConfiguration | `Program.cs` (via `ConfigurationResolver`) | `DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL` | string (required) |
| 2 | Environment / IConfiguration | `Program.cs` | `CorsConfig:AllowedOrigins` (string[]), `CorsConfig:AllowAnyOrigin` (bool) | optional |
| 3 | `Program.cs` | DI container | service registrations + JWT bearer + JWKS `ConfigurationManager` | C# code |
| 4 | `DatabaseMigrator` | `postgres-local` | DDL statements (CREATE / INDEX / DROP) | SQL |
| 5 | `Program.cs` | OS / Docker | bind to `0.0.0.0:8080` | TCP listener |
## Error Scenarios
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| Missing `DATABASE_URL` / `JWT_ISSUER` / `JWT_AUDIENCE` / `JWT_JWKS_URL` | `ConfigurationResolver.ResolveRequiredOrThrow` | env var + config key both empty/whitespace | Process exits non-zero with `InvalidOperationException` whose message names the missing env var and config key. Watchtower restarts but the new container hits the same failure. **Fix**: provide the value via env or `appsettings.json` |
| CORS misconfigured in Production (`CorsConfig:AllowedOrigins=[]` AND `CorsConfig:AllowAnyOrigin!=true`) | `CorsConfigurationValidator.EnsureSafeForEnvironment` at startup | hard-fail guard | Process exits with `InvalidOperationException("CORS is misconfigured: ...")`. **Fix**: set `CorsConfig:AllowedOrigins` to the production UI origins, or set `CorsConfig:AllowAnyOrigin=true` to opt in explicitly |
| `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`).
+120
View File
@@ -0,0 +1,120 @@
# 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. Token signatures are validated against **ECDSA P-256 public keys** retrieved from the central `admin` service's JWKS endpoint at startup and refreshed on the .NET `ConfigurationManager` default schedule.
## Public Interface
```csharp
public static class JwtExtensions {
// Env / config-key contract (string constants — referenced by tests + Program.cs).
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);
}
```
`AddJwtAuth` takes `IConfiguration` — there is no string-secret parameter. All three required values are resolved internally via `ConfigurationResolver.ResolveRequiredOrThrow` (env var first, then config key, else throw at startup). See `modules/program.md` for the resolver contract.
## Internal Logic
1. **Resolve three required values** via `ConfigurationResolver.ResolveRequiredOrThrow`:
- `JWT_ISSUER` / `Jwt:Issuer` — expected `iss` claim value.
- `JWT_AUDIENCE` / `Jwt:Audience` — expected `aud` claim value.
- `JWT_JWKS_URL` / `Jwt:JwksUrl` — HTTPS URL of `admin`'s JWKS document.
If any is missing or whitespace-only, the call throws `InvalidOperationException` at startup. There is **no dev fallback** for any of these values.
2. **Build a `ConfigurationManager<JsonWebKeySet>`** wired with:
- The resolved `jwksUrl`.
- A custom `JwksRetriever : IConfigurationRetriever<JsonWebKeySet>` (private nested class) that delegates the HTTP fetch to the supplied `IDocumentRetriever` and constructs a `JsonWebKeySet` from the returned JSON body.
- An `HttpDocumentRetriever { RequireHttps = true }` — plain HTTP JWKS URLs are rejected.
The manager caches the JWKS in memory and refreshes on the .NET `ConfigurationManager` default schedule. This schedule matches admin's `Cache-Control: public, max-age=3600` on `/.well-known/jwks.json` (see `../components/05_identity/description.md` for the discovery rationale). The custom retriever exists because `Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectConfigurationRetriever` targets the full OIDC discovery document, which `admin` does not expose; only the JWKS endpoint is published.
3. **Register `JwtBearer` authentication** with the following `TokenValidationParameters`:
| Parameter | Value | Notes |
|-----------|-------|-------|
| `ValidateIssuer` | `true` | `ValidIssuer = <resolved JWT_ISSUER>` |
| `ValidateAudience` | `true` | `ValidAudience = <resolved JWT_AUDIENCE>` |
| `ValidateLifetime` | `true` | |
| `ValidateIssuerSigningKey` | `true` | |
| `ValidAlgorithms` | `[SecurityAlgorithms.EcdsaSha256]` | Pinned — see Security §1 |
| `RequireSignedTokens` | `true` | |
| `RequireExpirationTime` | `true` | |
| `ClockSkew` | `TimeSpan.FromSeconds(30)` | Tighter than .NET default (5 minutes) |
| `IssuerSigningKeyResolver` | Delegate that fetches `JsonWebKeySet` via the cached `ConfigurationManager` and returns the subset whose `kid` matches the token header (or all keys when `kid` is empty) | Synchronous `GetAwaiter().GetResult()` over the async fetch — first call triggers the JWKS HTTP fetch and blocks until it completes; subsequent calls hit the cache |
4. **Register authorization policies** via `AddAuthorizationBuilder`:
- `"FL"` — requires a `permissions` claim with value `"FL"`.
- `"GPS"` — requires a `permissions` claim with value `"GPS"`. **Removed after Jira B7 lands** (the policy still exists today because `Controllers/FlightsController.cs` uses it for the GPS-Denied routes that B7 also removes).
`RequireClaim("permissions", <code>)` matches on a claim named `"permissions"` whose value equals the code. Multi-permission tokens typically have 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` — uses the **same ECDSA public-key model**: `admin` signs with the private key; every consumer fetches the public JWKS from `admin` and validates locally. The user logs in once at the UI; the resulting bearer token is reusable across every service.
Unlike a pure "validate locally, never call back" model, this service **does** contact `admin` once at startup (and on JWKS refresh) to fetch the JWKS document. Once cached, request-path validation is purely cryptographic and does not call `admin`. The first request after a cold start blocks on the JWKS fetch (single-digit ms typical on the local LAN); subsequent requests use the cached keys.
## Dependencies
- `Microsoft.AspNetCore.Authentication.JwtBearer` (NuGet, pinned to `10.0.5`)
- `Microsoft.IdentityModel.Protocols` (`ConfigurationManager<T>`, `HttpDocumentRetriever`, `IConfigurationRetriever<T>`, `IDocumentRetriever`)
- `Microsoft.IdentityModel.Tokens` (`JsonWebKeySet`, `TokenValidationParameters`, `SecurityAlgorithms`)
- `Azaion.Flights.Infrastructure.ConfigurationResolver` (internal — see `modules/program.md`)
No internal dependencies on other domain modules.
## Consumers
- `Program.cs``builder.Services.AddJwtAuth(builder.Configuration)` is called once at startup.
- Controllers reference the policies indirectly via `[Authorize(Policy = "FL")]` and (until B7) `[Authorize(Policy = "GPS")]`.
## Configuration
Reads three values via `ConfigurationResolver.ResolveRequiredOrThrow`:
| Env var | Config key | Required? | Purpose |
|---------|------------|-----------|---------|
| `JWT_ISSUER` | `Jwt:Issuer` | **Yes** (throws at startup if missing) | Expected `iss` claim value |
| `JWT_AUDIENCE` | `Jwt:Audience` | **Yes** (throws at startup if missing) | Expected `aud` claim value |
| `JWT_JWKS_URL` | `Jwt:JwksUrl` | **Yes** (throws at startup if missing) | HTTPS URL of admin's JWKS endpoint (e.g. `https://admin.azaion/.well-known/jwks.json`) |
Resolution order per value: `Environment.GetEnvironmentVariable(envVar)``IConfiguration[configKey]` → throw. No hardcoded fallback. No legacy `JWT_SECRET` is consulted.
## External Integrations
- **Outbound HTTPS to `admin`** for JWKS retrieval. Required at startup (the first protected request blocks on this fetch). `HttpDocumentRetriever.RequireHttps = true` rejects non-HTTPS URLs at configuration time. If `admin` is unreachable at the time of the first JWKS fetch, the first request fails with a 500 from the `IssuerSigningKeyResolver` delegate; the manager retries on the default refresh interval.
## Security
1. **Algorithm pinning**: `ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256]`. Pinning prevents the classic "HS256 confusion" attack — without this, an attacker who learned the JWKS public key could forge a token with `alg: HS256` using the public key as the HMAC secret, and stock JWT bearer validation would accept it. The pin forces ECDSA-SHA256 regardless of the JWT header's `alg` claim.
2. **HTTPS-only JWKS**: `HttpDocumentRetriever { RequireHttps = true }`. A plain-HTTP JWKS URL is rejected at configuration time. MITM substitution of the public key requires breaking TLS to `admin`.
3. **Issuer + audience binding**: `ValidateIssuer = true` and `ValidateAudience = true` are enforced. Tokens minted by a different issuer or for a different audience are rejected even if the signature is valid. This was the AZ-487 / AZ-494 finding in the prior HS256 model; it is now structurally fixed in code.
4. **Fail-fast on missing config**: `ConfigurationResolver.ResolveRequiredOrThrow` throws `InvalidOperationException` at startup if any of `JWT_ISSUER` / `JWT_AUDIENCE` / `JWT_JWKS_URL` is missing or whitespace-only. There is **no dev fallback**. A production deploy without these values cannot silently boot.
5. **Tight clock skew**: 30 seconds (`TimeSpan.FromSeconds(30)`) — tighter than .NET's 5-minute default and tighter than the legacy 1-minute setting. Reduces the window during which a token rejected for clock drift is still cryptographically valid.
6. **JWKS rotation model**: `admin` rotates by publishing a new `kid` in the JWKS; tokens signed under the previous `kid` remain valid until they expire. Because the `IssuerSigningKeyResolver` returns all keys when the token header has no `kid` and the matching subset when it does, both old and new tokens validate during the overlap window. **No coordinated re-deploy is needed** when keys rotate — this is the major operational improvement over the legacy shared-secret model.
## Tests
None present today; will be filled by the autodev BUILD pipeline (Steps 57 in the existing-code flow). Test-spec scope is in `_docs/02_document/tests/security-tests.md` (NFT-SEC-*).
## Notes / Smells
1. **Single permission (`FL`) gates the whole mission API.** All routes in `01_vehicle_catalog` and `02_mission_planning` 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. **Synchronous JWKS fetch on the first request after cold start**`IssuerSigningKeyResolver` calls `GetConfigurationAsync(...).GetAwaiter().GetResult()`. This blocks the worker thread until the JWKS document is fetched and parsed. On the local LAN this is single-digit ms; if `admin` is slow or unreachable, the first request takes the timeout hit. Subsequent requests use the cached keys without blocking.
3. **No authentication scheme name override** — uses `JwtBearerDefaults.AuthenticationScheme` ("Bearer"). Consistent.
4. **No claim type for "user id" is consumed** — only the `permissions` claim is checked. Whatever subject identity the issuer puts in the token is ignored at the policy layer. Audit logs / business rules that need a per-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.
5. **`JwksRetriever` is a hand-rolled minimal implementation** — `Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectConfigurationRetriever` is the stock retriever but it pulls the full OIDC discovery document; `admin` only exposes JWKS. The private nested class is ~5 lines and is the smallest correct adapter. If `admin` ever publishes a full OIDC discovery document, swapping to the stock retriever is a one-line change.
@@ -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).
+97
View File
@@ -0,0 +1,97 @@
# 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`** (PostgreSQL `NO ACTION` is the default `ON DELETE` behavior — see `service_mission.md` and `service_waypoint.md` for the in-code cascade walks that compensate):
- `missions.vehicle_id REFERENCES vehicles(id)`
- `waypoints.mission_id REFERENCES missions(id)`
- `map_objects.mission_id REFERENCES missions(id)`
- **Column types**: timestamps use PostgreSQL `TIMESTAMP` (no timezone) — `missions.created_date`, `map_objects.first_seen_at`, `map_objects.last_seen_at`. This means `DateTime.Kind` round-trips as `Unspecified` from the database; the application is the source of truth for "this value was stored as UTC" (`MissionService.CreateMission` writes `DateTime.UtcNow`).
- **Defaults**: enums default to `0`, decimals (`NUMERIC`) to `0`, booleans to `FALSE`, timestamps to `NOW()`, and the `map_objects.label` text column defaults to empty string `''`. Nullable columns (`waypoints.lat`, `waypoints.lon`, `waypoints.mgrs`, `map_objects.lat`, `map_objects.lon`) have no `DEFAULT` clause.
- **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.
+129
View File
@@ -0,0 +1,129 @@
# 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 via ConfigurationResolver.ResolveRequiredOrThrow
(env var DATABASE_URL -> config key Database:Url -> THROW).
If it begins with "postgresql://" -> ConvertPostgresUrl() to Npgsql key=value form.
3. Register services (scoped where applicable):
- AppDataConnection <- scoped, built via new DataOptions().UsePostgreSQL(connectionString)
- MissionService, WaypointService, VehicleService <- scoped
- AddJwtAuth(builder.Configuration) -> resolves JWT_ISSUER + JWT_AUDIENCE + JWT_JWKS_URL
(each via ResolveRequiredOrThrow). Registers JWT bearer + "FL" + "GPS" policies.
(GPS policy is removed in Jira B7.)
4. Resolve CORS configuration:
- allowedOrigins = Configuration.GetSection("CorsConfig:AllowedOrigins").Get<string[]>() ?? []
- allowAnyOrigin = Configuration.GetValue<bool>("CorsConfig:AllowAnyOrigin")
- CorsConfigurationValidator.EnsureSafeForEnvironment(allowedOrigins, allowAnyOrigin, EnvironmentName)
THROWS in Production when origins are empty AND AllowAnyOrigin is false (fail-fast guard).
5. Register CORS:
- If CorsConfigurationValidator.ShouldUsePermissivePolicy(...) -> AllowAnyOrigin/Method/Header
- Else -> WithOrigins(allowedOrigins).AllowAnyHeader().AllowAnyMethod()
6. Register MVC infra: AddControllers, AddEndpointsApiExplorer, AddSwaggerGen.
7. Build the WebApplication.
8. If CorsConfigurationValidator.ShouldWarnAboutPermissiveDefault(...) -> log warning
(logs PermissiveDefaultWarning with the resolved EnvironmentName).
9. Open a temp scope, resolve AppDataConnection, call DatabaseMigrator.Migrate(db).
10. Configure pipeline (order matters):
a. UseMiddleware<ErrorHandlingMiddleware>
b. UseCors
c. UseAuthentication
d. UseAuthorization
e. UseSwagger, UseSwaggerUI (unconditional — see ADR-005)
f. MapControllers
g. MapGet("/health", () => Results.Ok({status:"healthy"}))
11. 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)
```
The four required configuration values (`DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL`) all flow through `Infrastructure/ConfigurationResolver.cs``ResolveRequiredOrThrow(IConfiguration, envVar, configKey, humanLabel)`:
1. Read `Environment.GetEnvironmentVariable(envVar)`. If non-whitespace, return it.
2. Otherwise read `configuration[configKey]`. If non-whitespace, return it.
3. Otherwise throw `InvalidOperationException` with a human-readable message naming both the env var and the config key.
There is **no hardcoded fallback** for any of these values; an unset env var aborts startup. ADR-005 (in `architecture.md`) is obsolete for the secret-fallback aspect — only the Swagger-unconditional aspect of ADR-005 still applies today.
## 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 var | Config key | Required? | Default | Notes |
|---------|------------|-----------|---------|-------|
| `DATABASE_URL` | `Database:Url` | **Yes** | — (throws at startup if unset) | Either Npgsql key=value form OR a `postgresql://` URI (converted via `ConvertPostgresUrl`) |
| `JWT_ISSUER` | `Jwt:Issuer` | **Yes** | — (throws at startup if unset) | Expected `iss` claim value (per `modules/auth.md`) |
| `JWT_AUDIENCE` | `Jwt:Audience` | **Yes** | — (throws at startup if unset) | Expected `aud` claim value (per `modules/auth.md`) |
| `JWT_JWKS_URL` | `Jwt:JwksUrl` | **Yes** | — (throws at startup if unset) | HTTPS URL of admin's JWKS endpoint (per `modules/auth.md`) |
| `CorsConfig:AllowedOrigins` | `CorsConfig:AllowedOrigins` | No (defaults to empty) | `[]` | String array. When non-empty, the CORS policy uses `WithOrigins(...)` |
| `CorsConfig:AllowAnyOrigin` | `CorsConfig:AllowAnyOrigin` | No (defaults to `false`) | `false` | When `true`, the CORS policy uses `AllowAnyOrigin/Method/Header` regardless of origins. When `false` AND origins are empty AND environment is `Production`, startup THROWS via `CorsConfigurationValidator.EnsureSafeForEnvironment` |
| `ASPNETCORE_ENVIRONMENT` | (n/a — framework) | No | `Production` | Read by the framework. The CORS validator's hard-fail behavior triggers only in `Production` |
| `AZAION_REVISION` | (n/a) | No | `unknown` (Dockerfile build-arg default) | Set by `Dockerfile` from `CI_COMMIT_SHA` |
There is **no `appsettings.json`** in this repo today (per discovery), so all values flow through env vars in practice. Both env-var and config-key resolution are wired so that an `appsettings.json` (or any other `IConfiguration` source) can be added later without code changes. Suite-wide env conventions live in `../../suite/_docs/00_top_level_architecture.md` (Edge compose excerpt).
The legacy `JWT_SECRET` env var is **no longer consulted**`modules/auth.md` documents the ECDSA + JWKS replacement.
## External Integrations
- **PostgreSQL** (read/write) via Npgsql. Connection string resolved at startup; connection pool managed by Npgsql.
- **`admin` (JWKS)** — outbound HTTPS once at startup (and on JWKS refresh per the .NET `ConfigurationManager` default schedule). Subsequent request-path JWT validation uses the cached keys and does not call back. See `modules/auth.md` § External Integrations and § Security for details.
## Security
- **Fail-fast configuration**: `ConfigurationResolver.ResolveRequiredOrThrow` aborts startup if any of `DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL` is missing or whitespace-only. There are **no hardcoded dev fallbacks** for any of these values; a misconfigured production deploy cannot silently boot with a known-weak credential.
- **CORS is gated**: `CorsConfigurationValidator.EnsureSafeForEnvironment(...)` THROWS in `Production` when `CorsConfig:AllowedOrigins` is empty AND `CorsConfig:AllowAnyOrigin` is not `true`. In non-Production environments (e.g. local `dotnet run`, `Development`, `Staging`) an empty allow-list with `AllowAnyOrigin=false` falls back to permissive (`AllowAnyOrigin/Method/Header`) and emits the `PermissiveDefaultWarning` startup log. The "all-environments-permissive" model described in older docs no longer holds.
- **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. This is the **only remaining** aspect of ADR-005 that still applies after the fail-fast change; the "dev fallback secret" aspect of ADR-005 is now obsolete.
- **No HTTPS redirection** middleware (`UseHttpsRedirection`) — TLS is assumed to terminate at an upstream reverse proxy. The container `EXPOSE 8080` is plain HTTP.
- **HTTPS-only JWKS**: `HttpDocumentRetriever { RequireHttps = true }` (set inside `AddJwtAuth`) rejects plain-HTTP JWKS URLs at configuration time. A misconfigured `JWT_JWKS_URL=http://...` aborts startup.
- **`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. **CORS gating is `Production`-only at the hard-fail layer**. In `Staging` or any custom `ASPNETCORE_ENVIRONMENT` name that is not literal `Production` (case-insensitive), an empty allow-list with `AllowAnyOrigin=false` falls back to permissive instead of throwing. Operators deploying to a "Staging" tier that should be locked down need to set `CorsConfig:AllowedOrigins` explicitly — the validator will not enforce it for them.
3. **JWKS first-fetch is synchronous on the worker thread** (`GetAwaiter().GetResult()` inside the `IssuerSigningKeyResolver`). If `admin` is slow or unreachable when the first protected request arrives, that request blocks until the HTTP fetch returns. See `modules/auth.md` § Notes #2.
4. **`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.
5. **`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.
6. **`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.
7. **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.
+74
View File
@@ -0,0 +1,74 @@
# Ripple Log — Cycle 1 (2026-05-14 re-verification)
> **Source trigger**: `_docs/02_document/05_drift_findings_2026-05-14.md` — targeted re-verification of `Auth/JwtExtensions.cs`, `Program.cs`, `Infrastructure/ConfigurationResolver.cs`, `Infrastructure/CorsConfigurationValidator.cs`, `Database/DatabaseMigrator.cs`, `Services/AircraftService.cs`, `Services/FlightService.cs`, `Services/WaypointService.cs`.
> **Mode**: `document` skill in **Task mode** (re-run on previously "complete" docs). The drift was discovered AFTER the initial pass declared `current_step: complete`; this cycle is a targeted refresh.
## Files in the changed-source set (cycle trigger)
These code files are the **observed-current-state** that the docs were re-aligned against. None of them were modified during this documentation cycle — code stays as-is; only the docs change.
| Source file | Why it triggered ripple |
|-------------|--------------------------|
| `Auth/JwtExtensions.cs` | ECDSA-SHA256 + JWKS + iss/aud (was HS256 + shared-secret in docs) |
| `Program.cs` | Calls `ResolveRequiredOrThrow` + `CorsConfigurationValidator.EnsureSafeForEnvironment` (was hardcoded dev fallbacks in docs) |
| `Infrastructure/ConfigurationResolver.cs` | New file, no module doc previously existed |
| `Infrastructure/CorsConfigurationValidator.cs` | New file, no module doc previously existed |
| `Database/DatabaseMigrator.cs` | `TIMESTAMP` (not `TIMESTAMPTZ`); explicit `REFERENCES` on every FK; `DEFAULT` on every non-nullable non-key column |
| `Services/AircraftService.cs` | Case-INSENSITIVE name filter + `OrderBy(Name)` (docs said case-sensitive + no ordering) |
| `Services/FlightService.cs` | Case-INSENSITIVE name filter + `OrderByDescending(CreatedDate)` (docs didn't specify) |
| `Services/WaypointService.cs` | Composite `(missionId, waypointId)` predicate collapses two error cases into one 404 |
## Doc updates in this cycle
Direct updates driven by the drift findings:
| Doc | Reason |
|-----|--------|
| `_docs/02_document/modules/auth.md` | Full rewrite — ECDSA-JWKS model, iss/aud, alg pin, no shared secret |
| `_docs/02_document/modules/program.md` | Startup section rewrite — 4 required vars, fail-fast, CORS gate |
| `_docs/02_document/modules/database.md` | TIMESTAMP type, REFERENCES on FKs, DEFAULT clauses |
| `_docs/02_document/components/05_identity/description.md` | Mechanism + Caveats rewrite (matches `modules/auth.md`) |
| `_docs/02_document/components/07_host/description.md` | Configuration + CORS gating sections (matches `modules/program.md`) |
| `_docs/02_document/diagrams/flows/flow_jwt_validation.md` | Sequence + flowchart + data flow + error scenarios — full rewrite for JWKS |
| `_docs/02_document/diagrams/flows/flow_startup_migration.md` | Config resolution + CORS validation; no `JWT_SECRET` fallback |
| `_docs/02_document/architecture.md` | § Vision, § Components, § Major flows, § Principles, § Tech Stack (Auth row), § External Integrations (admin row), § Deployment env table, § Security, ADR-005 |
| `_docs/02_document/data_model.md` | ERD + Owned-table invariants — explicit TIMESTAMP, DEFAULT, REFERENCES |
| `_docs/02_document/system-flows.md` | Cross-cutting JWT + F5 + F6 detailed flows + error scenarios |
| `_docs/02_document/04_verification_log.md` | Re-issued § 3 F5 + F6 rows; demoted § 4.2 F3 CORS-unconditional; added § 4.3 |
| `_docs/00_problem/problem.md` | "What is", "Problem", "Users", "How it works", "Cross-cutting contracts" sections |
| `_docs/00_problem/restrictions.md` | E1, E3, E4, E9 — 4 env vars, no fallback, gated CORS |
| `_docs/00_problem/acceptance_criteria.md` | AC-1.5, AC-1.6, AC-2.3, AC-2.8, AC-4.2, AC-5 entire group (rewrite), AC-6.1, AC-6.2, AC-6.4, AC-6.5, AC-6.11, AC-6.12, AC-9.1 |
| `_docs/00_problem/security_approach.md` | § 1 (full rewrite), § 2 (FL claim semantics), § 3 (secrets), § 5 (CORS), § 6 (footguns), § 7 (audit) untouched, § 8 (threat model), § 9 (refs) |
| `_docs/00_problem/input_data/data_parameters.md` | § 1 env vars (4 required), § 2.1 / § 2.2 query case sensitivity, § 3 schema (TIMESTAMP, REFERENCES, DEFAULT) |
| `_docs/01_solution/solution.md` | Topology paragraph, component table rows 05 + 07, § 2.2 ADR-005 row, § 3.3 JWT scenario, § 5.1 + § 5.2 references |
## Import-graph ripple (computed, not provided by trigger)
Two new C# files were introduced under `Infrastructure/`:
- `Infrastructure/ConfigurationResolver.cs` (`Azaion.Flights.Infrastructure.ConfigurationResolver`)
- `Infrastructure/CorsConfigurationValidator.cs` (`Azaion.Flights.Infrastructure.CorsConfigurationValidator`)
Reverse-dependency scan (`rg "ConfigurationResolver|CorsConfigurationValidator"` in C# sources) finds **only `Program.cs` consumes them today**. No additional components are reached transitively. Both files belong to component `07_host` (composition root); they did NOT warrant a new component — the host doc was extended to cover them.
The JWT changes in `Auth/JwtExtensions.cs` (`Azaion.Flights.Auth.JwtExtensions`) are consumed only by `Program.cs`. The downstream `ClaimsPrincipal` is consumed by every `[Authorize(Policy="FL")]` controller, but the **wire-shape contract** of those controllers is unchanged — the policy still requires `permissions=FL`, the policy name is still `"FL"`. No component doc refresh needed beyond `05_identity` + `07_host`.
The DB schema changes (`TIMESTAMP`, `REFERENCES`, `DEFAULT`) ripple to:
- `_docs/02_document/data_model.md` (already in the direct list) — ERD + invariants.
- `_docs/00_problem/input_data/data_parameters.md` (already in the direct list) — § 3 schema tables.
- `_docs/00_problem/acceptance_criteria.md` AC-2.8 (already in the direct list) — TOCTOU mitigation via FK error 23503.
No further out-of-list ripple discovered.
## Verdict
All ripple-traced docs are included in the direct update list above; the import-graph scan surfaced no new candidates not already covered. The remaining suite-level docs (`../suite/_docs/05_identity*.md`, `../suite/_docs/00_roles_permissions.md`) likely carry correlated drift on the JWT model but are **out of scope** for this repo's `/autodev` cycle and are flagged in `04_verification_log.md` § 4.3 for the next suite-level autodev run.
## State at end of cycle
- All Phase 1 (doc revisions) tasks from `05_drift_findings_2026-05-14.md` are complete.
- Phase 2 (test-spec re-issue) is queued — next sub-skill invocation: `test-spec` in cycle-update mode.
- Phase 3 (resume Step 4) is the autodev step transition after Phase 2 lands.
- `_docs/02_document/state.json` is updated to record the re-verification entry.
- `_docs/_autodev_state.md` advances `sub_step` from `targeted-reverification-needed``complete`, then Step 1 → Step 2 (Plan) per the existing-code flow auto-chain.
+115
View File
@@ -0,0 +1,115 @@
{
"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"]
},
{
"trigger": "targeted re-verification of JWT/Config/CORS/DB-schema/filter drift against actual .cs source (2026-05-14, autodev cycle 1)",
"drift_findings_doc": "_docs/02_document/05_drift_findings_2026-05-14.md",
"ripple_log": "_docs/02_document/ripple_log_cycle1.md",
"areas_revised": [
"JWT validation model (HS256 shared-secret -> ECDSA-SHA256 + JWKS + iss/aud + alg pin)",
"Configuration resolution (hardcoded fallbacks -> ResolveRequiredOrThrow, 4 required vars)",
"CORS gating (always-permissive -> production-gated via CorsConfigurationValidator)",
"DB schema (TIMESTAMP not TIMESTAMPTZ; explicit REFERENCES on every FK; DEFAULT clauses)",
"Filter case sensitivity (case-sensitive in docs -> case-INSENSITIVE in code) + result ordering (unspecified -> documented)",
"Waypoint nested existence check (two-step in docs -> single composite predicate in code)"
],
"docs_touched": [
"modules/auth.md", "modules/program.md", "modules/database.md",
"components/05_identity/description.md", "components/07_host/description.md",
"diagrams/flows/flow_jwt_validation.md", "diagrams/flows/flow_startup_migration.md",
"architecture.md", "data_model.md", "system-flows.md", "04_verification_log.md",
"../00_problem/problem.md", "../00_problem/restrictions.md", "../00_problem/acceptance_criteria.md",
"../00_problem/security_approach.md", "../00_problem/input_data/data_parameters.md",
"../01_solution/solution.md"
],
"phase_2_complete": {
"completed_at": "2026-05-14T20:55:00Z",
"test_spec_files_touched": [
"../../docker-compose.test.yml",
"tests/environment.md",
"tests/test-data.md",
"tests/security-tests.md",
"tests/resilience-tests.md",
"tests/blackbox-tests.md",
"../00_problem/input_data/expected_results/results_report.md",
"tests/traceability-matrix.md"
],
"new_test_ids_added": ["NFT-SEC-04b", "NFT-SEC-10", "NFT-SEC-11", "NFT-SEC-12", "NFT-SEC-13"],
"rewritten_tests": ["NFT-SEC-02", "NFT-SEC-03", "NFT-SEC-04", "NFT-SEC-06", "NFT-RES-05", "NFT-RES-07", "FT-P-04", "FT-P-05", "FT-P-08", "FT-N-01"],
"coverage_after": "97% in-scope (was 93%)",
"uncovered_items_resolved": ["E3", "E9", "AC-6.2"]
},
"phase_3_pending": "resume autodev Step 4 (Code Testability Revision)"
}
],
"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,
"verification_re_run_2026_05_14": {
"doc": "_docs/02_document/04_verification_log.md (§ 4.3)",
"drift_findings": "_docs/02_document/05_drift_findings_2026-05-14.md",
"ripple_log": "_docs/02_document/ripple_log_cycle1.md",
"phase_1_doc_revisions": "complete",
"phase_2_test_spec_re_issue": "complete",
"phase_3_resume_step_4": "pending"
},
"last_updated": "2026-05-14T20:51:00Z"
}
+272
View File
@@ -0,0 +1,272 @@
# 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 ECDSA-SHA256 against `admin`'s JWKS, which this service fetches once at startup (lazy, on the first protected request) and caches via `Microsoft.IdentityModel.Protocols.ConfigurationManager<JsonWebKeySet>`; subsequent request-path validation does not call `admin`. The handler enforces `iss == JWT_ISSUER`, `aud == JWT_AUDIENCE`, `exp` (with 30-second clock skew), and pins `alg` to `EcdsaSha256` (defends against HS256-confusion). Failures surface as `401 Unauthorized` (no token / signature / claims / lifetime invalid) 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. Signature validation is local against admin's cached JWKS public keys; the only call to `admin` is the JWKS fetch (once at startup, plus refreshes on the default schedule). Request-path validation does NOT call `admin`.
**Preconditions**: `JWT_ISSUER`, `JWT_AUDIENCE`, and `JWT_JWKS_URL` all resolved at startup via `ConfigurationResolver.ResolveRequiredOrThrow` (any missing value aborts startup); the JWT bearer middleware was registered by `AddJwtAuth(builder.Configuration)` in `07_host`.
**Key sequence steps**:
1. Request arrives at the ASP.NET Core pipeline with `Authorization: Bearer <jwt>`.
2. `JwtBearerHandler`:
- Parse the token header.
- Reject unless `alg ∈ ValidAlgorithms` (pinned to `EcdsaSha256` — defends against HS256-confusion).
- Resolve signing key for the token's `kid` via the cached `ConfigurationManager<JsonWebKeySet>`. On a cold cache, this triggers a one-time HTTPS GET of `JWT_JWKS_URL` from `admin`.
- Verify ECDSA-SHA256 signature against the matching public key.
- Verify `iss == JWT_ISSUER`, `aud == JWT_AUDIENCE`, `exp` (with 30-second clock skew).
3. If algorithm, signature, claims, 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` |
| Forged `alg: HS256` token | Pipeline | `ValidAlgorithms` pin | `401`. Pin defense — see `05_identity` Caveats #6 |
| Invalid signature | Pipeline | ECDSA verify fails | `401` |
| `iss``JWT_ISSUER` | Pipeline | `ValidateIssuer = true` | `401` |
| `aud``JWT_AUDIENCE` | Pipeline | `ValidateAudience = true` | `401` |
| Expired token | Pipeline | `ValidateLifetime` (with 30s skew) | `401`; client re-authenticates with `admin` |
| `kid` not in cached JWKS | `IssuerSigningKeyResolver` | No matching key | `401`. Manager refreshes on default schedule; new `kid` becomes available there |
| `admin` unreachable on first JWKS fetch | `HttpDocumentRetriever` | `HttpRequestException` | First request fails 500. Subsequent requests retry on next refresh. **Operationally**: keep `admin` reachable from edge |
| `permissions` claim missing or not `"FL"` | Policy evaluator | Claim lookup | `403` |
| JWKS rotation on `admin` | `ConfigurationManager` refresh | next scheduled refresh tick | **No coordinated redeploy needed** — new keys are picked up on refresh; old tokens with the old `kid` remain valid until expiry |
---
## 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`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL` all resolve via `ConfigurationResolver.ResolveRequiredOrThrow` (any missing value aborts startup — no hardcoded fallbacks); `postgres-local` is reachable; the `azaion` database exists. `admin` does NOT need to be reachable at this point — the JWKS fetch is lazy on the first protected request.
**Key sequence steps**:
1. Container starts → entrypoint `dotnet Azaion.Missions.dll`.
2. `Program.cs` resolves `DATABASE_URL` via `ConfigurationResolver.ResolveRequiredOrThrow``ConvertPostgresUrl` → Npgsql connection string.
3. Calls `AddJwtAuth(builder.Configuration)`, which resolves `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL` (each via `ResolveRequiredOrThrow`), wires the `ConfigurationManager<JsonWebKeySet>`, and registers JWT bearer + `"FL"` (+ legacy `"GPS"` until B7) policies. No network call yet.
4. Reads `CorsConfig:AllowedOrigins` + `CorsConfig:AllowAnyOrigin`; runs `CorsConfigurationValidator.EnsureSafeForEnvironment` (throws in `Production` with implicit-permissive config); registers the CORS policy (permissive OR `WithOrigins`).
5. Registers controllers, middleware, scoped `AppDataConnection`, scoped service classes.
6. Builds the host. If implicit-permissive CORS applies (non-Production, empty origins, `AllowAnyOrigin=false`), logs `PermissiveDefaultWarning` at startup. 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;`.
7. Registers `ErrorHandlingMiddleware` FIRST in the pipeline; mounts CORS, auth, controllers, `MapGet("/health")`, Swagger UI.
8. `app.Run()` — ready to serve HTTP on port 8080.
**Error scenarios**:
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| Missing `DATABASE_URL` / `JWT_ISSUER` / `JWT_AUDIENCE` / `JWT_JWKS_URL` | Step 2 or 3 | `ResolveRequiredOrThrow` throws `InvalidOperationException` | Process exits non-zero with a message naming the env var and config key. Watchtower restarts, but the new container hits the same failure until the value is provided |
| CORS misconfigured in `Production` (empty origins + `AllowAnyOrigin != true`) | Step 4 | `EnsureSafeForEnvironment` throws | Process exits with `MissingOriginsMessage`. **Fix**: set `CorsConfig:AllowedOrigins` or explicit `AllowAnyOrigin=true` |
| `postgres-local` unreachable | Step 6 | Npgsql connection failure | Process exits non-zero. Watchtower restarts the container; `flight-gate` prevents restart mid-mission |
| `azaion` database does not exist | Step 6 | 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 6 (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 6 | 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.
+612
View File
@@ -0,0 +1,612 @@
# 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), ordered by Name ASC
**Summary**: Verifies `GET /vehicles` returns a non-paginated array — distinguishing it from `GET /missions` — and that results are ordered alphabetically by `Name` ASC (per AircraftService.GetVehicles `OrderBy(a => a.Name)`).
**Traces to**: AC-1.5
**Category**: Vehicle CRUD
**Preconditions**:
- `seed_3_vehicles_2_default` containing `BR-01`, `BR-02`, `MQ-9` (any insert order)
**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; `[v.Name for v in body] == ["BR-01", "BR-02", "MQ-9"]` (alphabetical ASC) |
**Expected outcome**: results_report.md AC-1 row 1.5.
**Max execution time**: 2s.
---
### FT-P-05: Vehicle filter by name + isDefault (case-INSENSITIVE name)
**Summary**: Verifies query-string filter — **case-INSENSITIVE** substring on `name` (LinqToDB renders `LOWER(name) LIKE %lower(input)%`), 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"` |
| 2 | `GET /vehicles?name=br&isDefault=true` (lowercase) | `200`; `body.length == 1`; `body[0].Name == "BR-01"` (case-INSENSITIVE match) |
**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, ordered by CreatedDate DESC
**Summary**: Verifies `GET /missions` returns `PaginatedResponse<Mission>` with default page size 20, ordered by `CreatedDate` DESC (newest first per `FlightService.GetMissions` `OrderByDescending(f => f.CreatedDate)`).
**Traces to**: AC-2.3, AC-8.7
**Category**: Mission CRUD
**Preconditions**:
- `seed_25_missions` with deterministic `CreatedDate` values spanning January-February 2026
**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`; for every `i` in `[0..18]`: `Items[i].CreatedDate >= Items[i+1].CreatedDate` (DESC ordering) |
| 2 | `GET /missions?name=re` (lowercase) against missions containing `"Recon-*"` names | `200`; `body.TotalCount > 0` — case-INSENSITIVE name filter matches Mission Name `"Recon-*"` |
**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 returns empty when no row matches case-insensitively
**Summary**: Verifies that `?name=` returns an empty body when no row's `Name` contains the substring (case-insensitive). This is the "no-match" half of AC-1.6 — distinct from FT-P-05 which asserts that lowercase input DOES match `BR-01`.
**Traces to**: AC-1.6
**Category**: Vehicle CRUD (negative)
**Preconditions**:
- `seed_3_vehicles_2_default` (`BR-01`, `BR-02`, `MQ-9`)
**Input data**: `GET /vehicles?name=ZZ` (substring `ZZ` is absent from every name)
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | `GET /vehicles?name=ZZ` | `200`; `body.length == 0` |
| 2 | `GET /vehicles?name=zz` (lowercase) | `200`; `body.length == 0` (still no match) |
**Expected outcome**: results_report.md AC-1 row 1.7.
**Note (drift, 2026-05-14)**: this test was previously titled "Vehicle name filter is case-sensitive" and asserted `?name=br → length 0`. That assertion was WRONG against the actual code (`a.Name.ToLower().Contains(query.Name.ToLower())` — case-insensitive). The test is rewritten to assert the genuine no-match case.
**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`).
+190
View File
@@ -0,0 +1,190 @@
# 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` |
| `jwks-mock` | build context `tests/Azaion.Missions.JwksMock/`; image tag `azaion/jwks-mock:test` | **In-process stand-in for the `admin` service's JWKS endpoint.** Holds a fixed ECDSA P-256 keypair (in-memory), serves the public key as JWKS at `https://jwks-mock:8443/.well-known/jwks.json` (HTTPS-only, self-signed CA mounted into `missions` and `e2e-consumer`), and signs tokens for the consumer via `POST /sign`. Supports `POST /rotate-key` for NFT-RES-07 (JWKS rotation). The mock's `Cache-Control: max-age` is set to 60s in tests (vs admin's 3600s) so rotation completes within the 15-min CI gate. | — (internal only) |
| `e2e-consumer` | build context `tests/Azaion.Missions.E2E.Tests/`; runs `dotnet test` | xUnit test runner; produces `report.csv`. Fetches signed test tokens from `jwks-mock` instead of minting locally — the private key never leaves `jwks-mock`, eliminating the class of bugs where the consumer signs with a key that doesn't match the published JWKS. | — |
| `pg-side` (optional) | reused `postgres-test` connection on a side port | Side-channel DB connection for fixture seeding + post-call assertions | shares `postgres-test` |
The only external services not running are the sibling backend services (`annotations`, `detection`, `autopilot`, `flight-gate`, Watchtower, suite reverse proxy) — their tables (`media`, `annotations`, `detection`, `map_objects`) are seeded directly by the side-channel for cascade tests; the services themselves are out of scope for service-level e2e.
The `jwks-mock` service replaces the pre-2026-05-14 "consumer mints HS256 tokens with a shared secret" pattern. The current code path (per `Auth/JwtExtensions.cs`) is ECDSA-SHA256 + JWKS — there is no shared secret to mint with anymore. See `test-data.md` § External Dependency Mocks for the mock's contract.
### Networks
| Network | Services | Purpose |
|---------|----------|---------|
| `e2e-net` | `missions`, `postgres-test`, `jwks-mock`, `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
The canonical compose file is `docker-compose.test.yml` at the repo root. Below is the abbreviated structural outline — see the actual file for the full healthchecks, depends_on, and volume mounts.
```yaml
services:
postgres-test:
image: postgres:16-alpine
environment:
POSTGRES_DB: azaion
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres-test
# healthcheck: pg_isready
jwks-mock:
build: { context: tests/Azaion.Missions.JwksMock }
environment:
JWT_ISSUER: https://admin-test.azaion.local
JWT_AUDIENCE: azaion-edge
OLD_KEY_GRACE_SECONDS: 5
# healthcheck: GET /.well-known/jwks.json over HTTPS (--no-check-certificate)
missions:
build: { context: . }
environment:
DATABASE_URL: postgresql://postgres:postgres-test@postgres-test:5432/azaion
JWT_ISSUER: https://admin-test.azaion.local
JWT_AUDIENCE: azaion-edge
JWT_JWKS_URL: https://jwks-mock:8443/.well-known/jwks.json
ASPNETCORE_ENVIRONMENT: Test # NOT Production -- CORS falls back to permissive with a warning log line
volumes:
- ./tests/jwks-mock-ca.crt:/usr/local/share/ca-certificates/jwks-mock-ca.crt:ro
depends_on:
postgres-test: { condition: service_healthy }
jwks-mock: { 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
JWKS_MOCK_SIGN_URL: https://jwks-mock:8443/sign
JWT_ISSUER: https://admin-test.azaion.local
JWT_AUDIENCE: azaion-edge
volumes:
- ./test-results:/app/results
- ./tests/jwks-mock-ca.crt:/usr/local/share/ca-certificates/jwks-mock-ca.crt:ro
depends_on:
missions: { condition: service_healthy }
jwks-mock: { condition: service_healthy }
```
**Production-gate (E9) variant**: for the CORS production-gate lock test (E9), the test runner spawns `missions` with `ASPNETCORE_ENVIRONMENT=Production` and an empty `CorsConfig:AllowedOrigins` and asserts startup THROWS `InvalidOperationException`. This variant runs OUTSIDE the main compose stack via `docker run` to avoid disturbing the rest of the suite.
## Consumer Application
**Tech stack**: xUnit 2.x + plain `HttpClient` against the dockerized `missions` service. Bogus 35.x for synthetic data. JWT acquisition via HTTPS `POST jwks-mock:8443/sign` (no in-process JWT library on the consumer side — the consumer treats tokens as opaque bearer strings). 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 <ECDSA-SHA256, iss=$JWT_ISSUER, aud=$JWT_AUDIENCE, 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` |
| JWKS mock sign endpoint | HTTPS/1.1 JSON | `https://jwks-mock:8443/sign` body `{ "iss":..., "aud":..., "exp":..., "permissions":..., ... }` returns signed JWT | none (test-network internal only) |
| JWKS mock JWKS endpoint | HTTPS/1.1 JSON | `https://jwks-mock:8443/.well-known/jwks.json` | none (consumed by `missions` itself, not by the test consumer) |
| JWKS mock rotate-key endpoint | HTTPS/1.1 JSON | `POST https://jwks-mock:8443/rotate-key` body `{}` returns `{ "newKid": "..." }` and starts the `OldKeyGraceSeconds` window | none |
### 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 JWT signing key in the consumer process — the consumer requests signed tokens from `jwks-mock` via HTTPS `POST /sign`. The ECDSA private key never leaves the mock container. This guarantees the test setup cannot drift away from "consumer-signed token matches missions-cached JWKS public key".
## 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
> Filled by autodev `/test-spec` Hardware Assessment phase (2026-05-14).
### Decision: Docker execution
The project is **NOT hardware-dependent**. Test execution is fully containerised; no local-mode runner is needed.
### Hardware dependencies found: NONE
Documentation scan (`restrictions.md`, `solution.md`, `architecture.md`, `components/*/description.md`):
- H1H6 cover edge-device deployment shape (Jetson Orin / OrangePI / operator-PC, multi-arch ARM64+AMD64, vertical scale only) but none of those concerns require hardware code paths inside the test runner — the suite-level CI matrix builds for both arches separately (per O4 + H2).
- No GPU / model inference / sensor / camera / GPIO / V4L2 mention in any docs.
- `solution.md` § 2.1 explicitly classifies every component as standard ASP.NET Core + linq2db over Postgres — no hardware adapter.
Code scan (`*.csproj`, `Dockerfile`, all `.cs` source):
- `Azaion.Flights.csproj` (post-B5: `Azaion.Missions.csproj`) packages: `linq2db 6.2.0`, `Npgsql 10.0.2`, `Microsoft.AspNetCore.Authentication.JwtBearer 10.0.5`, `Swashbuckle.AspNetCore 10.1.5`. None is hardware-specific.
- `Dockerfile`: stock `mcr.microsoft.com/dotnet/sdk:10.0` build stage + `mcr.microsoft.com/dotnet/aspnet:10.0` runtime stage. Multi-arch via `--platform=$BUILDPLATFORM` + `dotnet publish --os linux --arch $arch`. No `runtime: nvidia`, no GPU device mounts.
- No `RuntimeInformation.IsOSPlatform`, no `coreml` / `cuda` / `gpio` / `v4l2` / `opencl` / `vulkan` / `tpu` / `fpga` references in any production source file (verified via grep — matches were only in skill templates and docs, not in `Controllers/`, `Services/`, `Database/`, `Auth/`, `Middleware/`, `Program.cs`).
Multi-arch matters for the **production deploy** (per H2), but it does NOT affect the test runner: tests exercise the API black-box, identical on both architectures. The suite CI matrix (`.woodpecker/build-arm.yml` + the future `.woodpecker/build-amd.yml`) tests each arch in its own container.
### Execution instructions — Docker mode (the only mode)
**Prerequisites** on the test host:
- Docker Engine ≥ 24.0 with Docker Compose v2 plugin.
- 2.5 GB free RAM (1 GB for `missions`, 256 MB for `postgres-test`, 256 MB for `jwks-mock`, 512 MB for `e2e-consumer`, 512 MB headroom).
- 2 CPU cores recommended for the test wall-clock to fit under the 15-minute CI gate.
- Free TCP ports `5002` (host → `missions:8080`) and `5433` (host → `postgres-test:5432`) — used only when running outside compose. The `jwks-mock` HTTPS port (`8443` inside the container) is NOT mapped to the host; only `missions` and `e2e-consumer` reach it via the internal `e2e-net` network.
**Run command** (from repo root):
```bash
./scripts/run-tests.sh
```
The runner script (Phase 4) wires up `docker compose up`, waits for `missions` health, executes `dotnet test` inside `e2e-consumer`, collects `report.csv`, and tears down the compose stack. See `scripts/run-tests.sh` for the canonical command sequence.
**Performance tests**:
```bash
./scripts/run-performance-tests.sh
```
Same compose stack, but with the perf seed (1000 missions for NFT-PERF-04, 100 minimal missions for NFT-PERF-01) and the `[Trait("Category","Perf")]` filter. See `scripts/run-performance-tests.sh` (Phase 4).
### Resource ceiling
Total RAM: ≤ 2.5 GB for `missions + postgres-test + jwks-mock + e2e-consumer` together. Test wall-clock budget: ≤ 15 minutes (CI gate). Storage: ephemeral (the `pg-test-data` tmpfs is recreated per scenario class via `docker compose down -v` for the bootstrap-sensitive scenarios — NFT-RES-03, NFT-RES-04, NFT-RES-05, NFT-RES-06, NFT-RES-07).
@@ -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).
+221
View File
@@ -0,0 +1,221 @@
# 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: Required configuration missing → fail-fast at startup
**Summary**: Verifies AC-6.1 / AC-6.2 / E3 — `Infrastructure/ConfigurationResolver.ResolveRequiredOrThrow` throws `InvalidOperationException` when any of the four required env vars (`DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL`) is missing or whitespace-only. Also verifies AC-6.7 — DB unreachability (after config resolution succeeds) still causes process exit. The legacy "silent dev fallback boot" failure mode is structurally eliminated.
**Traces to**: AC-6.1, AC-6.2, AC-6.7, E3, E4
**Preconditions**:
- `missions` NOT running.
- This scenario uses `docker run` outside the main compose to isolate env-var manipulation.
**Steps** (each row is a separate `docker run` invocation; each times out at 30s):
| Step | Action | Expected Behavior |
|------|--------|------------------|
| 1 | `docker run --rm azaion/missions:test` with ALL four required env vars unset | container exits non-zero within 5s; logs contain `InvalidOperationException`; logs mention at least one of the four required keys |
| 2 | `docker run` with `DATABASE_URL` unset; the three JWT vars set correctly | same shape; logs mention `DATABASE_URL` or `Database:Url` |
| 3 | `docker run` with `JWT_ISSUER=""` (whitespace-only); other three set | same shape; logs mention `JWT_ISSUER` or `Jwt:Issuer` |
| 4 | `docker run` with `JWT_AUDIENCE` unset; others set | same shape; logs mention `JWT_AUDIENCE` or `Jwt:Audience` |
| 5 | `docker run` with `JWT_JWKS_URL` unset; others set | same shape; logs mention `JWT_JWKS_URL` or `Jwt:JwksUrl` |
| 6 | `docker compose stop postgres-test`, then start `missions` with all four env vars set correctly — config resolution succeeds, then DB-connect fails | container exits non-zero within 30s; logs contain a recognisable Npgsql connection error (e.g., `Connection refused`) — NOT an `InvalidOperationException` from the resolver (this differentiates "config missing" from "config valid but DB down") |
**Pass criteria**: rows 15 → fail-fast at config resolution; row 6 → fail at DB-connect AFTER config resolution succeeded.
**Note**: this test now exercises BOTH the fail-fast resolver (rows 15) AND the DB-unreachable case (row 6). Pre-revision, only row 6 was tested under the assumption of hardcoded dev fallbacks.
**Max execution time**: 180s (6 docker-run cycles).
---
### 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: JWKS key rotation — no missions restart required
**Summary**: Verifies AC-5.7 — rotating the signing key on `admin` (via `jwks-mock POST /rotate-key`) propagates to `missions` on the JWKS cache refresh tick **without restarting `missions`**. This is the primary operational win over the legacy shared-HMAC model, which required coordinated re-deploy across every backend on the device.
**Traces to**: AC-5.7
**Preconditions**:
- `missions` running with warm JWKS cache (any previous protected request succeeded).
- `jwks-mock` running with `Cache-Control: max-age=60` and `OldKeyGraceSeconds=5`.
- Token `T1` requested via `POST /sign` with the CURRENT `kid` (`kid_v1`), valid for 1h.
**Fault injection**: `POST https://jwks-mock:8443/rotate-key {}` — generates `kid_v2`, retains `kid_v1` in the JWKS for `OldKeyGraceSeconds`, then evicts `kid_v1`.
**Steps**:
| Step | Action | Expected Behavior |
|------|--------|------------------|
| 1 | `GET /vehicles` with `Authorization: Bearer T1` | `200` (cached JWKS knows `kid_v1`) |
| 2 | `POST https://jwks-mock:8443/rotate-key {}` → returns `kid_v2` | jwks-mock now publishes BOTH `kid_v1` and `kid_v2` in its JWKS for `OldKeyGraceSeconds=5` |
| 3 | Immediately request token `T2` signed with `kid_v2` via `POST /sign {}` | mock returns JWT with header `kid: kid_v2` |
| 4 | Immediately `GET /vehicles` with `Authorization: Bearer T2` (BEFORE `missions` JWKS cache refresh) | `401` (cache still only has `kid_v1`) |
| 5 | Wait up to 90s for `missions`'s `ConfigurationManager<JsonWebKeySet>` to refresh against the new JWKS (the mock's `max-age=60` triggers a refresh on the next request after that interval) | — |
| 6 | `GET /vehicles` with `Authorization: Bearer T2` again | `200` (cache now contains `kid_v2`) |
| 7 | `GET /vehicles` with `Authorization: Bearer T1` (still has unexpired lifetime, signed with `kid_v1`) | `200` IF the JWKS refresh happened BEFORE the mock's `OldKeyGraceSeconds=5` window closed (the JWKS still had `kid_v1`); `401` AFTER the grace window when `missions` refreshes and `kid_v1` is no longer in the JWKS. Test asserts the eventual `401` |
| 8 | Verify `missions` was NEVER restarted during this scenario (`docker inspect --format '{{.State.StartedAt}}' missions` is unchanged from before step 1) | startup timestamp unchanged |
**Pass criteria**: rotation propagates without restart; `T2` (new kid) eventually accepted; `T1` (old kid) eventually rejected; `missions` startup timestamp unchanged.
**Note**: this test replaces the pre-revision "shared-secret rotation requires coordinated redeploy" scenario. The pre-revision test asserted that ALL services on the device had to restart together; the post-revision test asserts the opposite — they do NOT have to restart.
**Max execution time**: 180s (longest wait is the JWKS refresh tick).
---
### 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.
+273
View File
@@ -0,0 +1,273 @@
# Security Tests
> **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14), revised in cycle-update mode (drift findings Phase 2) for the ECDSA+JWKS JWT model.
> **Naming**: post-rename target. Security tests focus on the JWT bearer + Authz boundary defined in AC-5 and AC-9.
> **Auth model**: ECDSA-SHA256 with JWKS retrieved from `admin` (mocked via `jwks-mock` in tests), iss + aud + alg-pin validated, 30s clock skew. The CMMC L2 row 3 (`iss`/`aud`) finding is now structurally fixed in code; the corresponding NFT-SEC scenarios now assert REJECTION, not acceptance.
---
### 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 whose ECDSA signature doesn't verify against any cached JWKS public key is rejected.
**Traces to**: AC-5.5
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Acquire valid signed token `T_good` from `jwks-mock POST /sign`, then flip a single byte in the token's signature segment to produce `T_bad` | — |
| 2 | `GET /vehicles` with `Authorization: Bearer T_bad` | `401` |
| 3 | Acquire token from a SEPARATE ECDSA keypair not present in the mock's published JWKS (via an out-of-band test helper) and call `GET /vehicles` | `401` |
**Pass criteria**: both bad-signature cases return `401`.
---
### NFT-SEC-03: Expired token outside skew → 401; inside skew → 200
**Summary**: Verifies AC-5.6 + AC-5.2 (**30s** clock skew, tighter than .NET's 5-min default and the legacy 1-min setting).
**Traces to**: AC-5.2, AC-5.6
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Request token via `POST /sign { "exp_offset_seconds": -60 }` (exp = now - 60s; outside the 30s skew) | mock returns signed JWT |
| 2 | `GET /vehicles` with `Authorization: Bearer T_exp` | `401` |
| 3 | Request token via `POST /sign { "exp_offset_seconds": -15 }` (exp = now - 15s; inside the 30s skew) | mock returns signed JWT |
| 4 | `GET /vehicles` with `Authorization: Bearer T_skew` | `200` |
**Pass criteria**: `T_exp` rejected; `T_skew` accepted.
---
### NFT-SEC-04: Token with `iss``JWT_ISSUER` → 401
**Summary**: Verifies AC-5.11 — `ValidateIssuer = true` with `ValidIssuer = <resolved JWT_ISSUER>`. This is the structural fix for CMMC L2 row 3 row (issuer validation half).
**Traces to**: AC-5.3, AC-5.11
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Request token via `POST /sign { "iss": "https://attacker.example.com" }` (every other claim valid) | mock returns signed JWT — the signature is correct, only `iss` is wrong |
| 2 | `GET /vehicles` with that token | `401` |
| 3 | Request token via `POST /sign { }` (iss defaults to the mock's `JWT_ISSUER` env, which matches `missions`'s configured `ValidIssuer`) | mock returns signed JWT |
| 4 | `GET /vehicles` with that token | `200` |
**Pass criteria**: wrong-`iss` rejected with 401; matching-`iss` accepted.
---
### NFT-SEC-04b: Token with `aud``JWT_AUDIENCE` → 401
**Summary**: Verifies AC-5.12 — `ValidateAudience = true` with `ValidAudience = <resolved JWT_AUDIENCE>`. This is the structural fix for CMMC L2 row 3 (audience validation half).
**Traces to**: AC-5.3, AC-5.12
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Request token via `POST /sign { "aud": "wrong-audience" }` (every other claim valid) | mock returns signed JWT |
| 2 | `GET /vehicles` with that token | `401` |
**Pass criteria**: wrong-`aud` rejected with 401.
---
### 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; multi-permission token accepted
**Summary**: Verifies AC-9.1 + AC-9.2 — the policy is `RequireClaim("permissions", "FL")` (contains-match, not exact body-match). Wrong values → 403; a multi-permission token where one value equals `"FL"` → 200.
**Traces to**: AC-9.1, AC-9.2
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Acquire token with `permissions="ADMIN"`, valid otherwise | mock returns signed JWT |
| 2 | `GET /vehicles` | `403` |
| 3 | Acquire token with `permissions="fl"` (lowercase) | mock returns signed JWT |
| 4 | `GET /vehicles` | `403` |
| 5 | Acquire token with `permissions="FLight"` | mock returns signed JWT |
| 6 | `GET /vehicles` | `403` |
| 7 | Acquire token with `permissions: ["FL", "ADMIN"]` (multi-permission array) | mock returns signed JWT |
| 8 | `GET /vehicles` | `200` (contains-match accepts `"FL"` among the values) |
**Pass criteria**: rows 2, 4, 6 → 403; row 8 → 200.
---
### 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.
---
### NFT-SEC-10: Algorithm-pin defends against HS256-confusion → 401
**Summary**: Verifies AC-5.1 + AC-5.10 — `ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256]` defends against the HS256-confusion attack where an attacker who learns a JWKS public key (which is, by definition, public) attempts to forge a token signed with that public key as the HMAC secret under `alg: HS256`.
**Traces to**: AC-5.1, AC-5.10
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Acquire the published JWKS public key bytes from `GET https://jwks-mock:8443/.well-known/jwks.json` (or use the mock's helper that returns those bytes for the test) | — |
| 2 | Acquire token via `POST /sign { "alg_override": "HS256" }` — the mock signs the JWT body with the public-key bytes as the HMAC secret (mimicking an attack) | mock returns HS256-signed JWT |
| 3 | `GET /vehicles` with `Authorization: Bearer T_hs256` | `401` |
| 4 | Acquire token via `POST /sign { "alg_override": "none" }` (mock emits unsigned JWT) | mock returns unsigned JWT |
| 5 | `GET /vehicles` with that token | `401` |
**Pass criteria**: both HS256-confusion attack and unsigned token are rejected with `401`.
---
### NFT-SEC-11: Unknown `kid` (rotation lag) → 401 until JWKS refresh
**Summary**: Verifies AC-5.7 — a token signed with a key whose `kid` is not in the cached JWKS is rejected; once the JWKS refreshes and includes the new `kid`, the same `kid` becomes accepted.
**Traces to**: AC-5.7
**Preconditions**:
- `missions` has a warm JWKS cache (any previous protected request succeeded).
- `jwks-mock` `OldKeyGraceSeconds = 5`.
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | `POST https://jwks-mock:8443/rotate-key {}` | mock returns `{ "newKid": "new-kid" }` |
| 2 | Immediately request token via `POST /sign {}` (signs with NEW kid, before `missions` JWKS cache refreshes) | mock returns signed JWT with header `kid: new-kid` |
| 3 | `GET /vehicles` with that token | `401` (cached JWKS doesn't yet contain `new-kid`) |
| 4 | Wait for `missions`'s JWKS cache to refresh (≤ 90s; the mock sets `Cache-Control: max-age=60` so the refresh tick is at most ~60s) | — |
| 5 | `GET /vehicles` with the same token (still valid lifetime) | `200` |
| 6 | Request token signed with the PREVIOUS `kid` within the `OldKeyGraceSeconds=5` window | mock signs with the old key |
| 7 | `GET /vehicles` with that token | `200` (both keys are in the JWKS during grace) |
| 8 | Wait > 5s, then request token signed with the OLD `kid` — mock should refuse (key already evicted from its sign-pool) | mock returns 400/410 |
**Pass criteria**: rotation completes transparently within the cache-refresh window; tokens minted with the new `kid` are rejected during the lag, accepted after.
**Max execution time**: 120s.
---
### NFT-SEC-12: Missing `JWT_JWKS_URL`/`JWT_ISSUER`/`JWT_AUDIENCE` → startup throws
**Summary**: Verifies AC-6.1 / AC-6.2 — `Infrastructure/ConfigurationResolver.ResolveRequiredOrThrow` fail-fast for each of the four required env vars eliminates the legacy "silent dev fallback" failure mode.
**Traces to**: AC-6.1, AC-6.2, E1, E3
**Steps** (run as four separate `docker run` invocations):
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | `docker run` `missions` with `DATABASE_URL` unset and the three JWT vars set | container exits non-zero within 5s; logs contain `InvalidOperationException` mentioning `DATABASE_URL` (or `Database:Url`) |
| 2 | `docker run` with `JWT_ISSUER` unset | container exits non-zero; logs mention `JWT_ISSUER` (or `Jwt:Issuer`) |
| 3 | `docker run` with `JWT_AUDIENCE` unset | container exits non-zero; logs mention `JWT_AUDIENCE` (or `Jwt:Audience`) |
| 4 | `docker run` with `JWT_JWKS_URL` unset | container exits non-zero; logs mention `JWT_JWKS_URL` (or `Jwt:JwksUrl`) |
| 5 | `docker run` with `JWT_JWKS_URL=http://jwks-mock:8443/...` (HTTP not HTTPS) and the other three set | container STARTS (config resolution passes); first protected request returns 500 with a log line mentioning HTTPS / `RequireHttps` |
**Pass criteria**: rows 14 → process exits before HTTP server binds; row 5 → process starts but the first protected request fails at JWKS fetch time.
**Max execution time**: 60s (4 docker-run cycles).
---
### NFT-SEC-13: CORS Production-gate fail-fast (E9 lock test)
**Summary**: Verifies AC-6.11 — in `ASPNETCORE_ENVIRONMENT=Production` with empty `CorsConfig:AllowedOrigins` and `CorsConfig:AllowAnyOrigin != true`, `CorsConfigurationValidator.EnsureSafeForEnvironment` throws and the process exits non-zero.
**Traces to**: AC-6.11, E9
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | `docker run` `missions` with `ASPNETCORE_ENVIRONMENT=Production` and **no** `CorsConfig` env vars | container exits non-zero within 5s; logs contain `InvalidOperationException` mentioning `CorsConfig` / `AllowedOrigins` / Production |
| 2 | Same as 1 but with `CorsConfig__AllowAnyOrigin=true` set | container starts; logs contain a warning that CORS is permissive in Production (recommend listing explicit origins) but NO throw |
| 3 | Same as 1 but with `CorsConfig__AllowedOrigins__0=https://operator.example.com` set | container starts; `OPTIONS /vehicles` preflight from `https://operator.example.com` returns `200` with the corresponding `Access-Control-Allow-Origin` echo |
| 4 | Same as 3, preflight from `https://attacker.example.com` | preflight responds without the allow-origin echo (the policy refuses the disallowed origin) |
| 5 | `docker run` with `ASPNETCORE_ENVIRONMENT=Development` (or unset → defaults to `Production`-no, actually unset defaults to `Production` per ASP.NET Core; use `Test` here) and no `CorsConfig` | container starts; logs contain `PermissiveDefaultWarning` |
**Pass criteria**: row 1 → fail-fast; rows 24 → start with the expected CORS posture; row 5 → start with the documented permissive fallback + warning.
**Max execution time**: 90s.
---
## Notes
- Tests that drop tables (NFT-SEC-08) run in a per-class fixture that recreates the schema before subsequent tests.
- NFT-SEC-04 / NFT-SEC-04b / NFT-SEC-10 / NFT-SEC-11 / NFT-SEC-12 / NFT-SEC-13 are NEW scenarios added in the 2026-05-14 drift re-verification cycle. They replace / extend the old "permissive iss/aud" + "shared-secret rotation" + "dev-fallback footgun" assumptions of the pre-revision spec.
- No fuzz testing today (recommended follow-up under a separate refactor cycle).
+121
View File
@@ -0,0 +1,121 @@
# 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-6.11 (CORS Production-gate lock test — separate `docker run` invocation), AC-5.7 (JWKS key 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; **ordered by `Name` ASC** | exact + schema + ordering | 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` (lowercase filter against `BR-01`) | `200`; body length == 1 (filter is **case-INSENSITIVE** per AC-1.6) | 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; **ordered by `CreatedDate` DESC** (newest first); `Items[0].CreatedDate >= Items[1].CreatedDate >= ...` | exact + schema + ordering | 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 + JWKS) | `jwks-mock` container | Built from `tests/Azaion.Missions.JwksMock/`, runs in the same `e2e-net` Docker network. Self-signed TLS cert; CA mounted into `missions` so it trusts the mock's JWKS HTTPS endpoint. Exposes `GET /.well-known/jwks.json` (consumed by `missions`'s `ConfigurationManager<JsonWebKeySet>`), `POST /sign` (returns ECDSA-signed JWTs to the test consumer for AC-5 + AC-9 scenarios), `POST /rotate-key` (generates a new `kid`, retains the previous public key for `OldKeyGraceSeconds` to support NFT-RES-07 transition assertions). The mock's private key never leaves its container. | Mints valid / expired / wrong-`kid` / wrong-`iss` / wrong-`aud` / wrong-`alg` (HS256 confusion) / claim-missing / claim-typo tokens on demand. Rotation tests trigger key roll without restarting `missions`. |
| `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 |
**JWKS mock token-minting contract** (consumed by the e2e test runner):
```http
POST https://jwks-mock:8443/sign HTTP/1.1
Content-Type: application/json
{
"iss": "https://admin-test.azaion.local", # optional; defaults to the mock's JWT_ISSUER env
"aud": "azaion-edge", # optional; defaults to JWT_AUDIENCE env
"exp_offset_seconds": 3600, # optional; default 3600 (1h). Negative for expired tokens.
"permissions": "FL", # optional; default "FL". Set to omit / "" / "ADMIN" / "fl" / "FLight" to test claim-mismatch
"alg_override": null, # optional; "HS256" forces the mock to sign with a static HMAC key for HS256-confusion tests (NFT-SEC-10)
"kid_override": null # optional; non-existent kid for unknown-key tests
}
```
Response: `{ "token": "<encoded JWT>", "kid": "<key id>" }`.
The mock signs every token with the current private key by default. `alg_override: "HS256"` is the only way to obtain an HS256 token in tests — used to verify the algorithm-pin defense (NFT-SEC-10).
## 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 **30s** clock skew | `exp = now-60s` | `401` |
| JWT signature | ECDSA-SHA256 + admin's JWKS | tampered payload / signed with non-JWKS key | `401` |
| JWT algorithm | `ValidAlgorithms = [EcdsaSha256]` (pinned) | `alg: HS256`, `alg: RS256`, `alg: none` | `401` |
| JWT `iss` claim | exact match against `JWT_ISSUER` | anything else | `401` |
| JWT `aud` claim | exact match against `JWT_AUDIENCE` | anything else | `401` |
| JWT `kid` (header) | must resolve in cached JWKS | unknown `kid` | `401` (until next JWKS refresh tick when rotation publishes it) |
| JWT claim `permissions` | contains-match `"FL"` (multi-permission tokens accepted if one entry == `"FL"`) | `"fl"`, `"ADMIN"`, `"FLight"`, missing | `403` |
| `Authorization` header | required on all `/vehicles/*`, `/missions/*` | absent | `401` |
| `DATABASE_URL` shape | `postgresql://...` URL OR raw Npgsql connection string | unparseable | `Infrastructure/ConfigurationResolver.ResolveRequiredOrThrow` throws `InvalidOperationException`; process exits non-zero before HTTP server binds |
| `JWT_ISSUER` / `JWT_AUDIENCE` / `JWT_JWKS_URL` | each required at startup | any of them missing or whitespace-only | `Infrastructure/ConfigurationResolver.ResolveRequiredOrThrow` throws `InvalidOperationException`; process exits non-zero before HTTP server binds |
| `JWT_JWKS_URL` scheme | HTTPS-only via `HttpDocumentRetriever { RequireHttps = true }` | `http://...` | passes startup config resolution, but first protected request fails 500 when JWKS fetch rejects the URL |
| `CorsConfig:AllowedOrigins` in `Production` | non-empty OR `AllowAnyOrigin == true` | empty AND `AllowAnyOrigin != true` AND `ASPNETCORE_ENVIRONMENT=Production` | `CorsConfigurationValidator.EnsureSafeForEnvironment` throws `InvalidOperationException`; startup aborts |
| `CorsConfig` in non-Production | empty allow-list permitted | n/a | falls back to `AllowAnyOrigin/Method/Header` AND logs `PermissiveDefaultWarning` |
@@ -0,0 +1,236 @@
# Traceability Matrix
> **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14); re-issued in cycle-update mode after the targeted re-verification of 2026-05-14 (drift findings Phase 2).
> **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.
> **Drift correction**: rows for AC-5, AC-6, AC-9, AC-1.5/1.6, AC-2.3, E1/E3/E4/E9 are updated below to reflect the ECDSA+JWKS JWT model, fail-fast configuration resolver, and CORS production-gate validator. Several `NOT COVERED` items in the pre-revision matrix are now `Covered` thanks to the new NFT-SEC-10..13 + NFT-RES-05 rewrite + the inverted FT-N-01.
## 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), ordered by `Name` ASC | FT-P-04 | Covered |
| AC-1.6 | Filter **case-INSENSITIVE** on `name`, exact on `isDefault` | FT-P-05 (positive + lowercase), FT-N-01 (no-match negative) | 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>`, ordered by `CreatedDate` DESC, name filter case-INSENSITIVE | FT-P-08 (ordering + case-INSENSITIVE), 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 between existence check and insert — now PARTLY mitigated by DB-level FK (PG `23503`); surface today is 500 | NOT directly covered as a separate test (deterministic reproduction still requires controllable concurrency); the FK mitigation is observable indirectly via 6.10 startup-schema test asserting `REFERENCES vehicles(id)` exists | 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 | **ECDSA-SHA256** with `ValidAlgorithms = [EcdsaSha256]` (algorithm pin) | NFT-SEC-02 (signature reject), NFT-SEC-10 (HS256-confusion defense) | Covered |
| AC-5.2 | `ValidateLifetime=true`, `ClockSkew=30s` | NFT-SEC-03 | Covered |
| AC-5.3 | `ValidateIssuer=true` + `ValidateAudience=true` (CMMC L2 row 3 structurally fixed in this service) | NFT-SEC-04, NFT-SEC-04b | Covered |
| AC-5.4 | Missing header → 401 | NFT-SEC-01 | Covered |
| AC-5.5 | Invalid signature / no matching public key → 401 | NFT-SEC-02 | Covered |
| AC-5.6 | Expired token (outside 30s skew) → 401 | NFT-SEC-03 | Covered |
| AC-5.7 | **JWKS key rotation without restart** — old kid eventually rejected, new kid eventually accepted | NFT-RES-07, NFT-SEC-11 | Covered |
| AC-5.8 | Missing `permissions=FL` claim → 403 | NFT-SEC-05 | Covered |
| AC-5.9 | Request-path validation local after JWKS cached; cold-start synchronously fetches JWKS | NFT-SEC-* all pass with `admin` not running (only `jwks-mock` runs); the cold-start failure mode is testable by stopping `jwks-mock` and restarting `missions` then issuing the first protected request | Covered (covered-cold-start case under results_report row 5.10) |
| AC-5.10 | Algorithm pin (`alg ∉ [EcdsaSha256]` → 401) | NFT-SEC-10 | Covered |
| AC-5.11 | `iss` validation (`iss != JWT_ISSUER` → 401) | NFT-SEC-04 | Covered |
| AC-5.12 | `aud` validation (`aud != JWT_AUDIENCE` → 401) | NFT-SEC-04b | Covered |
### AC-6 — Startup + migration
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage |
|-------|------------------------------|----------|----------|
| AC-6.1 | Four required env vars resolved via `ResolveRequiredOrThrow` (env-first, then `IConfiguration`, else throw); URL form converted via `ConvertPostgresUrl` | results_report 6.1, 6.1b, 6.1c; NFT-SEC-12; NFT-RES-05 | Covered |
| AC-6.2 | DATABASE_URL raw form accepted; no `JWT_SECRET` legacy env consulted | results_report 6.2; NFT-SEC-12 row 4 (asserts `JWT_JWKS_URL` is consulted, not `JWT_SECRET`) | Covered |
| 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-6.11 | CORS Production-gate fail-fast (empty allow-list + `AllowAnyOrigin != true` → throw) | NFT-SEC-13; results_report 6.106.13 | Covered |
| AC-6.12 | `JWT_JWKS_URL` HTTPS-only at fetch time (passes startup config) | NFT-SEC-12 row 5; results_report 6.1c | 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 as `RequireClaim("permissions", "FL")`**contains-match**: a multi-permission token containing `"FL"` is accepted | every protected-endpoint test + NFT-SEC-06 step 7-8 (multi-permission accepted) | Covered |
| AC-9.2 | Hardcoded string mismatch ("fl", "FLight", "ADMIN") → 403 | NFT-SEC-06 steps 2/4/6 | 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 §4 |
| 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 | **Four** required env vars (`DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL`) — fail-fast via `ResolveRequiredOrThrow` | NFT-SEC-12 (all 4 rows), NFT-RES-05 (all 5 rows), results_report 6.1b | Covered |
| E2 | DATABASE_URL accepts URL or raw form | URL form covered via every default test; raw form covered by results_report 6.2 | Covered |
| E3 | **No hardcoded dev fallbacks**`ResolveRequiredOrThrow` throws | NFT-SEC-12, NFT-RES-05 rows 1-5 | Covered |
| E4 | Asymmetric ECDSA: no shared secret on this side; only public-key configuration | NFT-SEC-* all run against `jwks-mock` (the mock holds the private key, this service holds only public-key config) | Covered |
| 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 **gated by `CorsConfigurationValidator`** — Production throws on empty allow-list | NFT-SEC-13 (all 5 rows), results_report 6.106.13 | Covered |
| E10 | TLS termination is suite reverse proxy; JWKS independently constrained to HTTPS | NFT-SEC-12 row 5 (JWKS HTTPS-only) | Covered (HTTPS half) |
### 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 | 12 | 12 | 0 | 0 | 0 | 100% |
| AC-6 Startup + migration | 12 | 12 | 0 | 0 | 0 | 100% |
| 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 | 7 | 1 | 0 | 2 | 100% (in-scope) |
| Restrictions O | 10 | 4 | 2 | 0 | 4 | 100% (in-scope) |
| **Total** | 117 | 78 | 10 | 3 | 26 | **97%** 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 (instrumented test build with `pg_advisory_lock`). Note: DB-level FK now produces PG error `23503` so the failure surface is consistent — only the timing of the race is hard to reproduce | 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-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) |
| 4 | S6 — Swagger NOT gated on `IsDevelopment()` (surviving branch of ADR-005) | 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. |
**Resolved by the 2026-05-14 re-verification**:
- E3 (hardcoded dev fallbacks) — structurally fixed in code via `ResolveRequiredOrThrow`. Old "Uncovered §6" obsolete; now Covered by NFT-SEC-12 + NFT-RES-05.
- E9 (CORS `AllowAnyOrigin/Method/Header` in all environments) — structurally fixed by `CorsConfigurationValidator`. Old "Uncovered §7" obsolete; now Covered by NFT-SEC-13.
- AC-6.2 / E2 (`DATABASE_URL` raw form path) — covered by results_report row 6.2 as part of the cycle-update; no longer a gap.
**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). Item 4 is a 1-test addition — add 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**: 97% in-scope (after the 2026-05-14 drift Phase 2 re-issue).
**Verdict**: **PASS** — Phase 3 gate cleared. The 4 remaining uncovered items are all low-medium risk with documented mitigations; the previous E3 / E9 / AC-6.2 gaps were closed by the structural code fixes already in `Infrastructure/ConfigurationResolver.cs` and `Infrastructure/CorsConfigurationValidator.cs`.
@@ -0,0 +1,59 @@
# Deferred to Step 8 (Refactor)
Items surfaced during Step 4 (Code Testability Revision) that are NOT testability blockers and would push beyond the "minimal, surgical" scope defined by the existing-code flow.
These items remain valid candidates for the optional Step 8 Refactor (or for a follow-up Phase B feature cycle once Phase A is complete).
---
## D-REF-01: Active JWKS refresh on unknown `kid`
**File**: `Auth/JwtExtensions.cs` (`IssuerSigningKeyResolver` lambda)
**Current behavior**: When a request arrives with a JWT whose `kid` header does not match any key in the locally-cached JWKS, the resolver returns an empty enumerable. The token is rejected with 401. The JWKS cache will not refresh until the next `AutomaticRefreshInterval` tick.
**Production impact**: After `admin` rotates its signing key, clients holding new-kid tokens are rejected for up to `AutomaticRefreshInterval` (currently library default = 12 hours). This is a real outage window after operator-driven key rotation.
**Proposed change** (Step 8): When `kid` is non-empty and no key matches, call `jwksConfigManager.RequestRefresh()` BEFORE returning empty, then return empty for THIS request only (the next request will see the refreshed cache). The `RefreshInterval` (default 5 min) bounds the refresh rate so a flood of bad-`kid` tokens cannot DOS the JWKS endpoint.
**Why deferred**: C01 (the optional refresh-interval env vars) is sufficient to make the documented tests pass. Active-refresh-on-miss is a production correctness improvement; per Step 4's "MINIMAL, SURGICAL" scope, it is intentionally out of scope.
---
## D-REF-02: Transaction-wrap cascade deletes
**File(s)**: `Services/FlightService.cs` (`DeleteFlight`), `Services/WaypointService.cs` (`DeleteWaypoint`), `Services/AircraftService.cs` (`CreateAircraft`, `SetDefault`, `UpdateAircraft`)
**Current behavior**: Cascade deletes and default-vehicle toggles execute as a series of independent `DELETE`/`UPDATE` statements without `BeginTransactionAsync`. Documented carry-forwards:
- AC-1.4 (default-vehicle clear+set not transactional → 2+ defaults under race)
- AC-3.3 (cross-table cascade not transactional → orphans on failure)
- AC-2.8 (TOCTOU between existence check and insert)
- AC-3.7 (autopilot orphan race on `map_objects` insert after step-1 read)
**Proposed change** (Step 8): Wrap each multi-statement block in `await using var tx = await db.BeginTransactionAsync()` + `await tx.CommitAsync()`. Add retry-with-jitter for serialization failures. Tests for the transactional variants would replace NFT-RES-08 (probabilistic) with deterministic transaction-rollback assertions.
**Why deferred**: Adding transactional boundaries is a structural change that affects every cascade path and requires coordinated schema review (FK actions, isolation level). The documented test spec already accepts the carry-forward via probabilistic tests and "Uncovered Items §1–§2" with documented mitigations.
---
## D-REF-03: Swagger gate on `IsDevelopment()`
**File**: `Program.cs` (lines 7475: `app.UseSwagger(); app.UseSwaggerUI();`)
**Current behavior**: Swagger UI is exposed in every environment, including Production. Carry-forward security finding S6 in `restrictions.md`.
**Proposed change** (Step 8): Wrap both calls in `if (app.Environment.IsDevelopment())`. No behavioral change in tests (the test environment uses `ASPNETCORE_ENVIRONMENT=Test`, which is NOT `Development` — but this matches the suite-level expectation that Swagger is a dev-only tool).
**Why deferred**: This is a Production-only security hardening; it does not block any documented test. Step 8 or a Phase B security task.
---
## D-REF-04: Single composite-FK existence check in `CreateFlight` / `CreateWaypoint`
**File(s)**: `Services/FlightService.cs` (`CreateFlight`), `Services/WaypointService.cs` (`CreateWaypoint`)
**Current behavior**: `CreateFlight` checks `Aircrafts.AnyAsync(...)` then INSERTs `Flight`. `CreateWaypoint` checks `Flights.AnyAsync(...)` then INSERTs `Waypoint`. The DB-level FK now catches the TOCTOU race (PG `23503`) but the API surface still does an extra round trip.
**Proposed change** (Step 8): Skip the explicit existence check on `CreateFlight` / `CreateWaypoint`; rely on the DB FK constraint and translate `PostgresException SqlState=23503` to `ArgumentException` in `ErrorHandlingMiddleware` (or service-layer catch). One fewer round trip per insert; race window eliminated structurally.
**Why deferred**: Affects error-message wording for non-existent parent IDs and requires `ErrorHandlingMiddleware` to learn about Postgres error codes. The test suite is structured around the current wording. Step 8 territory.
@@ -0,0 +1,56 @@
# List of Changes
**Run**: 01-testability-refactoring
**Mode**: guided
**Source**: autodev-testability-analysis (existing-code Phase A, Step 4)
**Date**: 2026-05-14
## Summary
Two minimal, surgical changes to make the documented test suite (`_docs/02_document/tests/`) actually executable against the running service. Without these, NFT-RES-07 + NFT-SEC-11 (JWKS rotation) cannot complete inside the 15-min CI gate, and EVERY JWT-dependent test fails at the TLS handshake against `jwks-mock` because the self-signed CA mounted into the container is never registered with the OS trust store. Both gaps are infrastructure-shaped — they do not change business logic, route shapes, validation semantics, DB schema, or any AC contract.
## Changes
### C01: Expose JWKS `ConfigurationManager` refresh intervals via optional env vars
- **File(s)**: `Auth/JwtExtensions.cs`
- **Problem**: `ConfigurationManager<JsonWebKeySet>` is constructed with library defaults — `AutomaticRefreshInterval = 12h` and `RefreshInterval = 5min`. Test scenario NFT-RES-07 ("JWKS key rotation — no missions restart required") expects rotation to propagate within ~90s; NFT-SEC-11 ("Unknown kid (rotation lag) → 401 until JWKS refresh") expects an unknown kid to be accepted within the same window after rotation. Neither can complete inside the 15-min CI wall-clock budget at the current defaults. The current `IssuerSigningKeyResolver` also does not call `RequestRefresh()` on cache-miss, so even a `kid` that was just published cannot be picked up until the next automatic refresh cycle.
- **Change**: Resolve two optional env vars / config keys via the existing `ConfigurationResolver`:
- `JWT_JWKS_AUTO_REFRESH_INTERVAL_SECONDS` (config key `Jwt:JwksAutoRefreshIntervalSeconds`) → if set and parses to a positive integer, sets `jwksConfigManager.AutomaticRefreshInterval = TimeSpan.FromSeconds(value)`. Default (unset): library default (12h) — production semantics unchanged.
- `JWT_JWKS_REFRESH_INTERVAL_SECONDS` (config key `Jwt:JwksRefreshIntervalSeconds`) → if set and parses to a positive integer, sets `jwksConfigManager.RefreshInterval = TimeSpan.FromSeconds(value)`. Default (unset): library default (5min).
Add a NEW resolver method `ConfigurationResolver.ResolveOptionalIntOrDefault(...)` returning `int?` for the parse-or-null path (parallel to the existing `ResolveRequiredOrThrow`). Whitespace-only or non-integer values throw `InvalidOperationException` at startup (fail-fast — consistent with the existing resolver contract); only unset/absent is treated as "use default".
In `docker-compose.test.yml` `missions` service, add `JWT_JWKS_AUTO_REFRESH_INTERVAL_SECONDS: "30"` and `JWT_JWKS_REFRESH_INTERVAL_SECONDS: "10"`.
- **Rationale**: Production stays at the library defaults (12h cache) — no behavioral change. Tests get a refresh tick fast enough to observe rotation. The knob is the minimum-surface fix; alternatives like calling `RequestRefresh()` from inside `IssuerSigningKeyResolver` on cache-miss would be a production correctness improvement (faster recovery from operator-driven key rotation in `admin`), but that crosses into Step 8 (Refactor) territory and is intentionally deferred per `_docs/04_refactoring/01-testability-refactoring/deferred_to_refactor.md`.
- **Constraint Fit**:
- AC-5.7 (JWKS rotation without restart) — preserved; this change makes the AC observable in tests within the 15-min budget.
- AC-5.9 (request-path validation local after JWKS cached) — preserved; the cache still operates entirely in-process between refresh ticks.
- E1 / E3 (no hardcoded dev fallbacks, fail-fast on required config) — preserved; the new env vars are OPTIONAL, and bad values (non-integer, whitespace) throw at startup — they never silently default.
- E4 (asymmetric ECDSA, no shared secret) — unchanged.
- Restriction set in `_docs/00_problem/restrictions.md` — no new restriction; the optional knob is a documented operator override, not a behavior shift.
- **Risk**: low — both new env vars default to "unset → library default". Production behavior is bit-for-bit identical unless an operator opts in.
- **Dependencies**: none
### C02: Register mounted CA certificates at container startup
- **File(s)**: `Dockerfile` (runtime stage), NEW `docker-entrypoint.sh` at repo root
- **Problem**: `docker-compose.test.yml` mounts the `jwks-mock` self-signed CA into the runtime container at `/usr/local/share/ca-certificates/jwks-mock-ca.crt`, but the runtime stage never invokes `update-ca-certificates` and the `ENTRYPOINT` execs `dotnet` directly. Debian's `/etc/ssl/certs/ca-certificates.crt` bundle is therefore never regenerated; .NET's `HttpDocumentRetriever` (used by `ConfigurationManager<JsonWebKeySet>`) rejects the HTTPS handshake to `https://jwks-mock:8443/...` with `RemoteCertificateNotAvailable`. EVERY JWT-dependent test fails at the first protected request — the service cannot fetch JWKS.
- **Change**:
- Add a small `docker-entrypoint.sh` at repo root that runs `update-ca-certificates --fresh >/dev/null 2>&1 || true` then `exec "$@"`. The `|| true` is acceptable here because `update-ca-certificates` only fails in pathological cases (e.g. unwriteable `/etc/ssl/`); the underlying TLS handshake will still produce a loud, informative error if trust is missing — we do not silence the actual security signal.
- Update `Dockerfile` runtime stage to `COPY docker-entrypoint.sh /docker-entrypoint.sh`, `RUN chmod +x /docker-entrypoint.sh`, and change `ENTRYPOINT ["dotnet", "Azaion.Flights.dll"]` to `ENTRYPOINT ["/docker-entrypoint.sh", "dotnet", "Azaion.Flights.dll"]`.
- No code change to the application.
- **Rationale**: Mounted CAs are how a sysadmin trusts a private CA on a Debian system; running `update-ca-certificates` at start is the standard pattern. Production benefits too: any operator deploying behind an enterprise PKI can mount their CA and it will be trusted, without rebuilding the image. The entrypoint wrapper is idempotent — if no extra CAs are mounted, the bundle stays identical to the base image's default.
- **Constraint Fit**:
- AC-5.* — preserved; the JWT validation logic itself is untouched. This change enables the validator to actually reach the JWKS endpoint over HTTPS.
- AC-6.12 (HTTPS-only JWKS URL) — preserved and now observable; the HTTPS handshake will SUCCEED against trusted CAs and FAIL against untrusted ones, which is the documented behavior.
- E4 / E10 (TLS termination at suite reverse proxy; JWKS independently constrained to HTTPS) — preserved; this change is downstream of E10.
- Container image size / startup cost: adds ~1ms (one `update-ca-certificates` invocation per cold start). Acceptable.
- **Risk**: low — `update-ca-certificates` is idempotent and ships pre-installed in the .NET runtime base image (`mcr.microsoft.com/dotnet/aspnet:10.0` is Debian-based). The only failure mode is a read-only `/etc/ssl/`, which would also break the existing image. The `|| true` guard avoids masking unrelated `dotnet` errors but does NOT mask TLS errors (the actual JWKS HTTPS call will still surface a `RemoteCertificateNotAvailable` if trust is genuinely broken).
- **Dependencies**: none
## Deferred to Step 8 (Refactor)
The following items were CONSIDERED during Step 4 analysis but rejected as out-of-scope for testability (recorded in `deferred_to_refactor.md`):
- **Active JWKS refresh on unknown `kid`** — change `IssuerSigningKeyResolver` to call `jwksConfigManager.RequestRefresh()` when no key matches the supplied `kid`. This is a production correctness improvement (recovery from operator-driven rotation falls from ~12h to a single round-trip) but is OUT of scope for testability — C01's interval knob alone is sufficient to make the documented tests pass.
- **Transaction-wrap cascade deletes** (AC-3.3, AC-3.7, AC-2.8) — addressing the TOCTOU + orphan races requires `BeginTransactionAsync` + retry; structural change deferred to Step 8 as already noted in `restrictions.md` and traceability-matrix Uncovered Items §1–§2.
- **Swagger production-gate** (S6 carry-forward) — single `IsDevelopment()` check; structural footgun but not a testability blocker. Deferred.
@@ -0,0 +1,62 @@
# Testability Changes Summary (01-testability-refactoring)
**Date**: 2026-05-14
**Trigger**: autodev existing-code flow, Step 4 (Code Testability Revision)
**Build status**: `dotnet build -c Release` — 0 warnings, 0 errors. No lint findings on modified files.
Applied 2 change(s):
## Config extraction
- **C01** — extended JWT configuration in `Auth/JwtExtensions.cs` + `Infrastructure/ConfigurationResolver.cs` + `docker-compose.test.yml`: added two NEW optional env vars (`JWT_JWKS_AUTO_REFRESH_INTERVAL_SECONDS`, `JWT_JWKS_REFRESH_INTERVAL_SECONDS`) and corresponding config keys (`Jwt:JwksAutoRefreshIntervalSeconds`, `Jwt:JwksRefreshIntervalSeconds`) that, when set, override the JWKS `ConfigurationManager`'s `AutomaticRefreshInterval` and `RefreshInterval`. Production leaves both unset → library defaults (12h / 5min). The test compose sets them to 30s / 10s so NFT-RES-07 (JWKS rotation) and NFT-SEC-11 (unknown-kid lag) can complete inside the 15-minute CI gate. A new `ConfigurationResolver.ResolveOptionalPositiveIntOrThrow` enforces the same fail-fast contract as `ResolveRequiredOrThrow` — typos and non-positive values throw at startup, never silently default. Risk: low.
## Container infrastructure
- **C02** — added `docker-entrypoint.sh` at repo root + adjusted `Dockerfile` runtime stage: container now runs `update-ca-certificates --fresh` (when available) before `exec`ing `dotnet Azaion.Flights.dll`. This makes the test-mounted `jwks-mock` self-signed CA actually trusted by the OS bundle that .NET's `HttpClient` (used by `HttpDocumentRetriever`) reads from. Without this, EVERY JWT-dependent test failed at the HTTPS handshake against `jwks-mock` (cert untrusted). Production benefits too — operators behind enterprise PKIs can mount a CA via volume without rebuilding the image. The wrapper is a no-op when no extra CAs are mounted. Risk: low.
## Files touched
| File | Change |
|------|--------|
| `Auth/JwtExtensions.cs` | +2 const pairs (env var + config key); +2 optional resolves; +2 conditional assignments to `jwksConfigManager.AutomaticRefreshInterval` / `RefreshInterval` |
| `Infrastructure/ConfigurationResolver.cs` | +`ResolveOptionalPositiveIntOrThrow` method (parallel to existing required resolver, returns `int?`, throws on bad parse / non-positive) |
| `docker-compose.test.yml` | +`JWT_JWKS_AUTO_REFRESH_INTERVAL_SECONDS: "30"` + `JWT_JWKS_REFRESH_INTERVAL_SECONDS: "10"` under `missions.environment`; comment block documents the rationale |
| `Dockerfile` | Runtime stage: `COPY docker-entrypoint.sh /docker-entrypoint.sh`, `RUN chmod +x …`, `ENTRYPOINT ["/docker-entrypoint.sh", "dotnet", "Azaion.Flights.dll"]` |
| `docker-entrypoint.sh` | NEW (repo root, executable). Runs `update-ca-certificates --fresh` then `exec "$@"`. |
## Files NOT touched
- `Services/{Aircraft,Flight,Waypoint}Service.cs` — already DI-driven; no testability gap
- `Database/{AppDataConnection,DatabaseMigrator}.cs` — already takes connection as argument
- `Controllers/*Controller.cs` — pure DI, no static state
- `Middleware/ErrorHandlingMiddleware.cs` — already DI-driven
- `Infrastructure/CorsConfigurationValidator.cs` — already pure utility taking config in
- `Program.cs` — every config value already resolved via env / fail-fast; CORS already gated
- Any production restriction surface (`restrictions.md`, ACs, schema) — all preserved
## Deferred to Step 8 / Refactor
See `deferred_to_refactor.md` in this folder. Summary:
- D-REF-01: active JWKS refresh on unknown `kid` (production correctness — currently up to 12h lag after operator-driven key rotation in `admin`)
- D-REF-02: transactional cascade deletes (AC-1.4 / AC-2.8 / AC-3.3 / AC-3.7 carry-forwards)
- D-REF-03: Swagger production gate (S6 carry-forward)
- D-REF-04: single composite-FK existence check on create (eliminates one round trip + TOCTOU race)
## Verification
- `dotnet build -c Release`**PASS** (0 warnings, 0 errors).
- `ReadLints` on `Auth/JwtExtensions.cs` + `Infrastructure/ConfigurationResolver.cs`**PASS** (no findings).
- No new `using` directives required; no new NuGet packages added.
- Existing `_docs/02_document/tests/environment.md` description of the test stack is unchanged — the new env vars and CA wiring are infrastructure-only and do not alter any observable AC contract.
## Constraint preservation matrix
| Constraint | Preservation evidence |
|------------|-----------------------|
| AC-5.* (JWT validation semantics) | Unchanged — only the JWKS cache refresh cadence is tunable; algorithm pin, `iss`/`aud`/`alg`/`exp` validation, signing-key resolution all identical |
| AC-5.7 (JWKS rotation without restart) | NOW OBSERVABLE within 15-min CI gate (C01) |
| AC-6.1 / E1 / E3 (fail-fast on required config, no dev fallbacks) | Preserved — new optional vars use the same fail-fast resolver pattern; bad values throw |
| AC-6.12 (HTTPS-only JWKS URL) | Preserved + reachable now (C02 makes the HTTPS handshake actually work against the test mock) |
| E4 (asymmetric ECDSA, no shared secret) | Unchanged |
| E10 (TLS termination at suite reverse proxy; JWKS independently HTTPS) | Preserved; the entrypoint wrapper only affects CA trust, never disables TLS |
| Production behavior | Bit-for-bit identical when the new env vars are unset and no CA is mounted (the dominant production case) |
+17
View File
@@ -0,0 +1,17 @@
# Autodev State
## Current Step
flow: existing-code
step: 5
name: Decompose Tests
status: not_started
sub_step:
phase: 0
name: blocked-on-tracker-auth
detail: "atlassian MCP not connected; tracker decision A/B/C/D pending"
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,50 @@
# Leftover: Step 5 (Decompose Tests) blocked on tracker auth
**Recorded**: 2026-05-14T20:51:00Z (Thursday)
**Blocker**: `user-atlassian-mcp` returns "Not connected" (verified via `getAccessibleAtlassianResources`).
**Type**: tracker availability — NOT a deferrable "non-user blocker"; the autodev tracker rule (`.cursor/rules/tracker.mdc` Tracker Availability Gate) requires explicit user decision (Retry / `tracker: local`).
## What is pending
Step 5 (Decompose Tests, tests-only mode) needs to run:
1. **Step 1t** — Test Infrastructure Bootstrap → creates `todo/[TRACKER-ID]_test_infrastructure.md` + matching Jira ticket
2. **Step 3** — Blackbox Test Task Decomposition → produces one task file per blackbox/perf/res/sec/res-lim scenario referenced in `_docs/02_document/tests/*.md`. Estimated 1220 task files based on the current spec spread (FT-P-01…FT-P-18, FT-N-01…FT-N-08, NFT-PERF-01…NFT-PERF-04, NFT-RES-01…NFT-RES-08, NFT-SEC-01…NFT-SEC-13, NFT-RES-LIM-01…NFT-RES-LIM-04).
3. **Step 4** — Cross-Verification → produces `_docs/02_tasks/_dependencies_table.md` and verifies AC/restriction coverage.
Each task file must have a Jira ticket created inline (per `.cursor/skills/decompose/SKILL.md` Save Timing table) and then be renamed from numeric prefix to `AZ-<id>` prefix.
## Inputs ready
- `_docs/02_document/tests/environment.md`
- `_docs/02_document/tests/test-data.md`
- `_docs/02_document/tests/blackbox-tests.md`
- `_docs/02_document/tests/performance-tests.md`
- `_docs/02_document/tests/resilience-tests.md`
- `_docs/02_document/tests/security-tests.md`
- `_docs/02_document/tests/resource-limit-tests.md` (need to verify exists)
- `_docs/02_document/tests/traceability-matrix.md` ✓ (post-2026-05-14 drift Phase 2 re-issue, 97% in-scope coverage)
- `_docs/00_problem/{problem,restrictions,acceptance_criteria}.md` ✓ (post-drift-revision)
## Resolution paths
The next `/autodev` invocation MUST resolve one of:
- **(preferred) Retry auth**: User authenticates `user-atlassian-mcp` via Cursor's MCP UI; autodev then proceeds normally and creates AZ-prefixed task files with live Jira tickets.
- **`tracker: local` mode** (only with explicit user acceptance): tasks are written with numeric prefix + `Tracker: pending` header marker; state file's `tracker:` field is changed to `local`; a future invocation with a working Jira MCP runs a "Tracker Pending Sync" to back-fill tickets and rename the files.
## Step 4 deliverables (already applied — DO NOT redo)
- `Auth/JwtExtensions.cs` — JWKS refresh-interval optional config (C01)
- `Infrastructure/ConfigurationResolver.cs``ResolveOptionalPositiveIntOrThrow` helper (C01)
- `Dockerfile` + new `docker-entrypoint.sh` — runs `update-ca-certificates` at container start (C02)
- `docker-compose.test.yml` — passes 30s / 10s JWKS refresh intervals to `missions` (C01)
- `_docs/04_refactoring/01-testability-refactoring/{list-of-changes,deferred_to_refactor,testability_changes_summary}.md`
`dotnet build -c Release` clean (0 warnings, 0 errors). `ReadLints` clean on edited files.
## Replay procedure when Atlassian MCP is back
1. On next `/autodev`, the Bootstrap step (B1) reads this leftover, verifies MCP connectivity via `getAccessibleAtlassianResources`, and either:
- **MCP works** → delete this leftover, autodev proceeds to Step 5 normally.
- **MCP still down** → autodev surfaces the Choose A/B/C/D again (see `protocols.md`).
2. If the user chose `tracker: local` in the interim and tasks were created with numeric prefixes, the next "Tracker Pending Sync" walks `_docs/02_tasks/todo/*.md` looking for `Tracker: pending` headers, creates the matching Jira ticket per task, rewrites the header, and renames the file from `NN_xxx.md` to `AZ-<id>_xxx.md`.
@@ -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.
+140
View File
@@ -0,0 +1,140 @@
## Test compose stack for the missions service.
## Naming: post-rename target. Pre-rename code path runs the same compose against the
## existing Azaion.Flights.csproj entrypoint -- tests will be RED until B5-B8 land.
## Documented in _docs/02_document/tests/environment.md.
##
## Post-2026-05-14 drift re-verification: JWT model is ECDSA-SHA256 with JWKS
## fetched from the `admin` service. Tests provide a `jwks-mock` container that
## stands in for `admin` -- it holds a fixed ECDSA P-256 keypair, serves the
## public key as JWKS over HTTPS at `https://jwks-mock/.well-known/jwks.json`,
## and signs test tokens on demand at `https://jwks-mock/sign`. The consumer
## fetches signed tokens from the mock; missions validates them against the
## mock's JWKS. The private key never leaves the mock container.
services:
postgres-test:
image: postgres:16-alpine
container_name: missions-postgres-test
environment:
POSTGRES_DB: azaion
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres-test
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d azaion"]
interval: 1s
timeout: 1s
retries: 30
networks:
- e2e-net
tmpfs:
## Ephemeral PG data; recreated per `docker compose down -v`.
- /var/lib/postgresql/data
jwks-mock:
## Build context populated by Step 6 (Implement Tests). The mock is a tiny
## ASP.NET Core / Python / Node app that:
## - Holds a fixed ECDSA P-256 keypair (in-memory; never exported).
## - Serves `GET /.well-known/jwks.json` over HTTPS with `Cache-Control:
## public, max-age=60` (60s instead of admin's 3600s so tests can observe
## rotation within a single 15-minute CI window).
## - Serves `POST /sign` over HTTPS accepting a claims JSON body and
## returning a signed JWT (ECDSA-SHA256) for test consumption.
## - Supports `POST /rotate-key` to generate a new keypair with a new
## `kid`; the prior public key stays in the JWKS for `OldKeyGraceSeconds`
## to verify the rotation transition (used by NFT-RES-07).
## - Self-signs its TLS certificate; the `missions` container trusts the
## mock's CA via a mounted volume at /etc/ssl/certs/jwks-mock-ca.crt.
## - Image tag: `azaion/jwks-mock:test`. Until built, run-tests.sh prints
## a clear "jwks-mock not yet built" message.
build:
context: tests/Azaion.Missions.JwksMock
dockerfile: Dockerfile
container_name: missions-jwks-mock
environment:
JWT_ISSUER: https://admin-test.azaion.local
JWT_AUDIENCE: azaion-edge
OLD_KEY_GRACE_SECONDS: 5
healthcheck:
test: ["CMD-SHELL", "wget -q --no-check-certificate -O - https://127.0.0.1:8443/.well-known/jwks.json || exit 1"]
interval: 2s
timeout: 1s
retries: 30
networks:
- e2e-net
missions:
build:
context: .
container_name: missions-sut
environment:
DATABASE_URL: postgresql://postgres:postgres-test@postgres-test:5432/azaion
JWT_ISSUER: https://admin-test.azaion.local
JWT_AUDIENCE: azaion-edge
JWT_JWKS_URL: https://jwks-mock:8443/.well-known/jwks.json
## Shorten the JWKS cache so NFT-RES-07 + NFT-SEC-11 can observe rotation
## within the 15-minute CI wall-clock budget. Production leaves both
## unset and inherits the library defaults (12h / 5min).
JWT_JWKS_AUTO_REFRESH_INTERVAL_SECONDS: "30"
JWT_JWKS_REFRESH_INTERVAL_SECONDS: "10"
ASPNETCORE_URLS: http://+:8080
ASPNETCORE_ENVIRONMENT: Test
## CORS: Test environment (NOT Production) -- empty allow-list falls back
## to permissive with a PermissiveDefaultWarning log line (per
## CorsConfigurationValidator). Production-gate scenarios (E9 lock test)
## set ASPNETCORE_ENVIRONMENT=Production and assert startup THROWS.
## The jwks-mock CA cert is mounted so missions can validate the mock's
## TLS cert when fetching JWKS over HTTPS. The container ENTRYPOINT runs
## update-ca-certificates on startup so the mounted CA is trusted by the
## OS bundle that .NET HttpClient reads from.
volumes:
- ./tests/jwks-mock-ca.crt:/usr/local/share/ca-certificates/jwks-mock-ca.crt:ro
ports:
- "5002:8080"
depends_on:
postgres-test:
condition: service_healthy
jwks-mock:
condition: service_healthy
healthcheck:
## Per AC-7.1, /health is anonymous. Container is "healthy" once /health returns 200.
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:8080/health || exit 1"]
interval: 2s
timeout: 1s
retries: 30
networks:
- e2e-net
e2e-consumer:
## Build context placeholder -- populated by Step 6 (Implement Tests) when the
## test csproj is created. Until then, run-tests.sh detects the absence and
## prints a clear "test project not yet created" message.
build:
context: tests/Azaion.Missions.E2E.Tests
dockerfile: Dockerfile
container_name: missions-e2e
environment:
MISSIONS_BASE_URL: http://missions:8080
DB_SIDE_CHANNEL: Host=postgres-test;Port=5432;Database=azaion;Username=postgres;Password=postgres-test
## Consumer fetches test tokens from jwks-mock instead of minting locally:
## the private key never leaves the mock container, so tests can't
## accidentally sign with a key that doesn't match the mock's published JWKS.
JWKS_MOCK_SIGN_URL: https://jwks-mock:8443/sign
JWT_ISSUER: https://admin-test.azaion.local
JWT_AUDIENCE: azaion-edge
depends_on:
missions:
condition: service_healthy
jwks-mock:
condition: service_healthy
volumes:
- ./test-results:/app/results
- ./tests/jwks-mock-ca.crt:/usr/local/share/ca-certificates/jwks-mock-ca.crt:ro
networks:
- e2e-net
profiles:
- test
networks:
e2e-net:
name: missions-e2e-net
+34
View File
@@ -0,0 +1,34 @@
#!/bin/sh
# Container startup wrapper for the missions service.
#
# Registers any CA certificates mounted into /usr/local/share/ca-certificates/
# with the system trust store, then execs the original ENTRYPOINT command.
#
# Why this exists:
# .NET HttpClient (used by the JwtBearer JWKS retriever) trusts only CAs in
# /etc/ssl/certs/ca-certificates.crt on Debian-based images. A CA file
# dropped into /usr/local/share/ca-certificates/ is NOT picked up until
# `update-ca-certificates` regenerates the bundle. Because the test harness
# mounts the jwks-mock CA at runtime (not build time), we have to run this
# on every container start.
#
# Production semantics:
# When no extra CAs are mounted, `update-ca-certificates --fresh` is a
# no-op that rewrites the bundle from the OS-provided certs unchanged.
# Operators deploying behind an enterprise PKI can mount their CA and have
# it trusted without rebuilding the image.
#
# Error handling:
# We `|| true` only the CA-update step itself (the only failure mode is a
# read-only /etc/ssl/, which would break the existing image too). We do NOT
# swallow errors from the wrapped dotnet command -- those propagate normally
# via `exec`. A genuinely broken TLS trust chain still surfaces loudly when
# the JWKS HTTPS handshake fails.
set -eu
if command -v update-ca-certificates >/dev/null 2>&1; then
update-ca-certificates --fresh >/dev/null 2>&1 || true
fi
exec "$@"
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env bash
## Performance test runner for the missions service.
## Scenarios: NFT-PERF-01 (cascade P50 <=50ms), NFT-PERF-03 (health P50 <=10ms),
## NFT-PERF-02 (cascade with full chain, regression baseline), NFT-PERF-04
## (mission list P95, regression baseline). Spec lives in
## _docs/02_document/tests/performance-tests.md.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
COMPOSE_FILE="$PROJECT_ROOT/docker-compose.test.yml"
RESULTS_DIR="$PROJECT_ROOT/test-results"
TEST_PROJECT_DIR="$PROJECT_ROOT/tests/Azaion.Missions.E2E.Tests"
KEEP_RUNNING=false
for arg in "$@"; do
case "$arg" in
--keep-running) KEEP_RUNNING=true ;;
-h|--help)
cat <<USAGE
Usage: scripts/run-performance-tests.sh [--keep-running]
Runs NFT-PERF-* scenarios from _docs/02_document/tests/performance-tests.md
against the dockerized missions service.
--keep-running Do NOT teardown the docker compose stack on exit (useful
for inspecting timing artifacts).
Thresholds (read from performance-tests.md):
NFT-PERF-01 cascade-delete P50 <= 50ms
NFT-PERF-02 cascade-delete-full P50 <= 200ms (provisional)
NFT-PERF-03 /health P50 <= 10ms
NFT-PERF-04 /missions paginated P95 <= 100ms (provisional)
USAGE
exit 0 ;;
*)
echo "unknown arg: $arg" >&2
exit 64 ;;
esac
done
cleanup() {
local exit_code=$?
if [ "$KEEP_RUNNING" = "true" ]; then
echo "[run-perf] --keep-running set; leaving compose stack up." >&2
else
echo "[run-perf] tearing down compose stack..." >&2
docker compose -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
fi
exit "$exit_code"
}
trap cleanup EXIT
mkdir -p "$RESULTS_DIR"
## --- Install dependencies ---
command -v docker >/dev/null 2>&1 || {
echo "[run-perf] ERROR: docker is required but not installed on PATH." >&2
exit 2
}
docker compose version >/dev/null 2>&1 || {
echo "[run-perf] ERROR: docker compose v2 plugin is required." >&2
exit 2
}
if [ ! -d "$TEST_PROJECT_DIR" ]; then
cat >&2 <<MSG
[run-perf] WARNING: test project not yet created at:
$TEST_PROJECT_DIR
Performance scenarios are defined in _docs/02_document/tests/performance-tests.md
but no test code exists yet to execute them. Step 6 (Implement Tests) introduces
the [Trait("Category","Perf")] test methods this script invokes.
MSG
exit 0
fi
## --- Start system under test ---
echo "[run-perf] starting compose stack..." >&2
docker compose -f "$COMPOSE_FILE" up -d --build postgres-test missions
echo "[run-perf] waiting for missions /health (timeout 60s)..." >&2
ATTEMPTS=0
until [ "$ATTEMPTS" -ge 60 ]; do
if curl -sf http://localhost:5002/health >/dev/null 2>&1; then
break
fi
ATTEMPTS=$((ATTEMPTS + 1))
sleep 1
done
if [ "$ATTEMPTS" -ge 60 ]; then
echo "[run-perf] ERROR: missions did not become healthy within 60s." >&2
exit 3
fi
## --- Run perf scenarios ---
## The scenarios live in the same xUnit project as the blackbox suite, but are
## tagged [Trait("Category","Perf")] so they only run under this filter. Each
## scenario reports its computed P50/P95 to test-results/perf.csv.
echo "[run-perf] running performance scenarios..." >&2
docker compose -f "$COMPOSE_FILE" --profile test build e2e-consumer
docker compose -f "$COMPOSE_FILE" --profile test run --rm \
-e PERF_RESULTS_FILE=/app/results/perf.csv \
e2e-consumer dotnet test \
/app/Azaion.Missions.E2E.Tests.csproj \
--filter Category=Perf \
--logger "trx;LogFileName=perf.trx"
TEST_EXIT=$?
## --- Compare against thresholds ---
## The xUnit Perf tests already enforce per-scenario thresholds (NFT-PERF-* `Pass
## criteria` in performance-tests.md). A failed assertion -> non-zero TEST_EXIT.
## This script just propagates the verdict; per-scenario detail is in perf.csv.
if [ "$TEST_EXIT" -eq 0 ]; then
echo "[run-perf] ALL THRESHOLDS MET." >&2
if [ -f "$RESULTS_DIR/perf.csv" ]; then
echo "[run-perf] per-scenario detail: $RESULTS_DIR/perf.csv" >&2
fi
else
echo "[run-perf] THRESHOLD FAILURES (exit code $TEST_EXIT)." >&2
echo "[run-perf] missions logs (last 50 lines):" >&2
docker compose -f "$COMPOSE_FILE" logs --tail=50 missions >&2 || true
fi
exit "$TEST_EXIT"
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env bash
## Test runner for the missions service (blackbox + unit tests).
## Documented in _docs/02_document/tests/environment.md (Hardware Assessment -> Docker mode).
## Naming: post-rename target. Pre-rename code path runs the same script -- tests
## will be RED until B5-B8 land.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
COMPOSE_FILE="$PROJECT_ROOT/docker-compose.test.yml"
RESULTS_DIR="$PROJECT_ROOT/test-results"
TEST_PROJECT_DIR="$PROJECT_ROOT/tests/Azaion.Missions.E2E.Tests"
UNIT_ONLY=false
KEEP_RUNNING=false
for arg in "$@"; do
case "$arg" in
--unit-only) UNIT_ONLY=true ;;
--keep-running) KEEP_RUNNING=true ;;
-h|--help)
cat <<USAGE
Usage: scripts/run-tests.sh [--unit-only] [--keep-running]
--unit-only Skip blackbox / e2e suite (run unit tests only). Currently
the missions service has no unit tests, so this is a no-op
until Step 6 (Implement Tests) introduces a test project.
--keep-running Do NOT teardown the docker compose stack on exit (useful
for debugging a failing test).
USAGE
exit 0 ;;
*)
echo "unknown arg: $arg" >&2
exit 64 ;;
esac
done
cleanup() {
local exit_code=$?
if [ "$KEEP_RUNNING" = "true" ]; then
echo "[run-tests] --keep-running set; leaving compose stack up." >&2
else
echo "[run-tests] tearing down compose stack..." >&2
docker compose -f "$COMPOSE_FILE" down -v --remove-orphans >/dev/null 2>&1 || true
fi
exit "$exit_code"
}
trap cleanup EXIT
mkdir -p "$RESULTS_DIR"
## --- Install dependencies ---
## docker compose handles per-image dependency resolution (dotnet restore inside
## the missions Dockerfile, dotnet restore inside the e2e-consumer Dockerfile).
## Verify Docker + Compose are available on the host.
command -v docker >/dev/null 2>&1 || {
echo "[run-tests] ERROR: docker is required but not installed on PATH." >&2
exit 2
}
docker compose version >/dev/null 2>&1 || {
echo "[run-tests] ERROR: docker compose v2 plugin is required." >&2
exit 2
}
if [ ! -d "$TEST_PROJECT_DIR" ]; then
cat >&2 <<MSG
[run-tests] WARNING: test project not yet created at:
$TEST_PROJECT_DIR
This is expected until Step 6 (Implement Tests) of the autodev existing-code
flow has run. The compose stack will be brought up so you can manually exercise
the missions service via curl, but no automated tests will execute and the
exit code will be 0 (vacuous success).
To create the test project, follow:
- _docs/02_document/tests/environment.md (consumer app spec)
- the Step 6 task tickets that decompose-tests will produce
MSG
echo "[run-tests] starting compose stack (without e2e-consumer)..." >&2
docker compose -f "$COMPOSE_FILE" up -d postgres-test missions
docker compose -f "$COMPOSE_FILE" ps
exit 0
fi
## --- Build images and start system under test ---
echo "[run-tests] building images..." >&2
docker compose -f "$COMPOSE_FILE" build postgres-test missions
echo "[run-tests] starting postgres-test + missions..." >&2
docker compose -f "$COMPOSE_FILE" up -d postgres-test missions
echo "[run-tests] waiting for missions /health (timeout 60s)..." >&2
ATTEMPTS=0
until [ "$ATTEMPTS" -ge 60 ]; do
if curl -sf http://localhost:5002/health >/dev/null 2>&1; then
echo "[run-tests] missions is healthy." >&2
break
fi
ATTEMPTS=$((ATTEMPTS + 1))
sleep 1
done
if [ "$ATTEMPTS" -ge 60 ]; then
echo "[run-tests] ERROR: missions did not become healthy within 60s." >&2
docker compose -f "$COMPOSE_FILE" logs missions >&2 || true
exit 3
fi
if [ "$UNIT_ONLY" = "true" ]; then
echo "[run-tests] --unit-only: missions service has no unit tests today." >&2
echo "[run-tests] (Step 6 may add a tests/Azaion.Missions.UnitTests/ project later.)" >&2
exit 0
fi
## --- Blackbox / e2e tests ---
echo "[run-tests] building e2e-consumer..." >&2
docker compose -f "$COMPOSE_FILE" --profile test build e2e-consumer
echo "[run-tests] running e2e suite via dotnet test..." >&2
## --abort-on-container-exit + --exit-code-from propagate the e2e-consumer exit
## code back to this script so the CI gate fires correctly.
docker compose -f "$COMPOSE_FILE" --profile test up \
--abort-on-container-exit \
--exit-code-from e2e-consumer \
e2e-consumer postgres-test missions
TEST_EXIT=$?
## --- Summary ---
if [ "$TEST_EXIT" -eq 0 ]; then
echo "[run-tests] ALL PASS." >&2
else
echo "[run-tests] FAILURES detected (exit code $TEST_EXIT)." >&2
echo "[run-tests] missions logs (last 50 lines):" >&2
docker compose -f "$COMPOSE_FILE" logs --tail=50 missions >&2 || true
echo "[run-tests] e2e-consumer logs (last 100 lines):" >&2
docker compose -f "$COMPOSE_FILE" logs --tail=100 e2e-consumer >&2 || true
fi
exit "$TEST_EXIT"