Files
missions/_docs/02_document/architecture.md
T
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

365 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Azaion.Missions — Architecture
> **NOTE (forward-looking)**: this document reflects the **post-rename, post-GPS-Denied-removal** target. Today's source still uses `Azaion.Flights` namespace, `Aircraft*`/`Flight*`/`Orthophoto*`/`GpsCorrection*` filenames, `[Route("aircrafts"|"flights")]`, and migrates 6 tables. The renames + drops are tracked under Jira AZ-EPIC + child tickets B5B12 (see `_docs/_process_leftovers/2026-05-14_rename-flights-to-missions.md`). The doc IS the spec for that work.
## Architecture Vision
> **Status**: confirmed-by-user (autodev `/document` Step 4.5, 2026-05-14). Source-of-truth for "what this service is and why" — downstream skills (`/refactor`, `/decompose`, `/new-task`, `/code-review`) consume this section before reading the lower-level technical sections below.
`missions` is the **edge-tier .NET 10 REST service** that owns the **mission domain** of each Azaion deployment — vehicle inventory, mission plans, waypoint sequences, and the cross-service cascade-delete that keeps the rest of the edge stack consistent when missions or waypoints are removed. **Exactly one instance runs per device** (Jetson Orin / OrangePI / operator-PC) alongside sibling edge services (`annotations`, detection, `autopilot`, `gps-denied`, `ui`), all sharing **ONE local PostgreSQL with per-service table ownership enforced by convention**. JWTs are minted remotely by the central `admin` service and validated locally with a shared HMAC secret; this service never calls back. The dominant pattern is **thin controller → service → linq2db active-record over a per-request scoped `DataConnection`**, with **no repository abstraction** and **no in-process message queue / event bus**.
### Components & responsibilities (6 logical components, 1 csproj)
| # | Component | Responsibility |
|---|-----------|----------------|
| 01 | `01_vehicle_catalog` | Vehicle CRUD + "is_default" exclusivity (stricter than spec — B12 decision pending) |
| 02 | `02_mission_planning` | Mission + Waypoint CRUD + the cross-service cascade-delete walk (canonical owner of the full mission ownership graph) |
| 04 | `04_persistence` | `AppDataConnection` (LinqToDB) + `DatabaseMigrator` (`CREATE TABLE IF NOT EXISTS` for the 4 owned tables post-B7 + B9) |
| 05 | `05_identity` | `JwtExtensions`; shared-secret HS256 validation; one `"FL"` policy (post-B7) |
| 06 | `06_http_conventions` | `ErrorHandlingMiddleware` + `PaginatedResponse<T>` + the unused `ErrorResponse` DTO |
| 07 | `07_host` | `Program.cs` composition root; runs migrator at startup; serves on port 8080 |
### Major data flows (7 — see `system-flows.md` for full sequences)
- **F1 Vehicle CRUD** — operator UI → vehicle service → DB.
- **F2 Mission create/read/update** — UI → mission service, with vehicle existence check.
- **F3 Mission delete + CASCADE** *(critical)* — walks across `annotations` + detection schemas; **not transaction-wrapped today** (ADR-006).
- **F4 Waypoint CRUD** — delete is a scoped F3 cascade.
- **F5 JWT bearer validation** — every protected request; local HS256, no `iss`/`aud` (CMMC L2 finding, suite-tracked under AZ-487 / AZ-494).
- **F6 Startup + schema migration** — `Program → DatabaseMigrator.Migrate → app.Run`.
- **F7 Health probe** — anonymous `GET /health`; process-liveness only.
### Architectural principles / non-negotiables (inferred from the code)
- **One PostgreSQL per device; per-service table ownership enforced by convention.** *[inferred-from: `../../suite/_docs/00_top_level_architecture.md` § Database Topology, `Database/AppDataConnection.cs`, `Database/DatabaseMigrator.cs`]*
- **Manual cascade-delete in code, NOT `ON DELETE CASCADE` in schema.** *[inferred-from: `Database/DatabaseMigrator.cs`, `FlightService.DeleteFlight` (today's `MissionService.DeleteMission`)]*
- **JWT validated locally with no callback to `admin`** (HS256 shared-secret). *[inferred-from: `Auth/JwtExtensions.cs`]*
- **Forward-only-additive schema bootstrap** (`CREATE TABLE IF NOT EXISTS`); B9's `DROP TABLE IF EXISTS` is the one explicit destructive step. *[inferred-from: `Database/DatabaseMigrator.cs`]*
- **Layer-organized layout** (`Controllers/`, `Services/`, `DTOs/`, `Enums/`), NOT feature-folders; one project / one root namespace; layering rules in `module-layout.md` enforced by convention not by the compiler. *[inferred-from: repository tree + `Azaion.Flights.csproj` (today's `Azaion.Missions.csproj`)]*
- **`gps-denied` is decoupled by design** — no runtime call in either direction; rows reference `mission_id` / `waypoint_id` as plain GUIDs in `gps-denied`'s own tables. *[inferred-from: ADR-007 + AZ-546 acceptance criteria]*
- **Watchtower-restart + `flight-gate` is the ONLY orchestration**; no Kubernetes; vertical scale only (one instance per device). *[inferred-from: `Dockerfile` + `../../suite/_docs/00_top_level_architecture.md`]*
### Carry-forward concerns (acknowledged, NOT in this Epic's scope)
These divergences from spec or known foot-guns are tracked in `00_discovery.md` § Spec ↔ Code Divergences and called out in component / module docs. They are deliberately deferred:
- PascalCase entity-body wire shape vs spec's camelCase (the *error envelope* is already camelCase by accidental match — see ADR-002).
- Cascade-delete is not transaction-wrapped (ADR-006); one-line fix to land opportunistically with B6.
- Swagger UI + dev-fallback secrets (`JWT_SECRET`, `DATABASE_URL`) NOT gated on `IsDevelopment()` (ADR-005).
- `"FL"` policy code retains the legacy "Flight" wording even after the service rename — fleet-wide auth change, not in this Epic.
- `Geopoint` stored as 3 flat columns (`lat`, `lon`, `mgrs`) instead of spec's single auto-converting `string GPS`.
- F2 returns `400` instead of spec's `404` on a missing `VehicleId` (`ArgumentException` mapping).
- `ErrorResponse` DTO is dead on the wire and has the wrong shape (`List<string>?` instead of spec's `object?` keyed by field name).
## 1. System Context
**Problem being solved**: Provide the edge-tier (.NET) service that owns the **mission domain** of an Azaion deployment — vehicle inventory (Plane / Copter / UGV / GuidedMissile), mission plans, waypoint sequences, and the cross-service cascade-delete that keeps the rest of the edge stack consistent when missions or waypoints are removed. The service runs **on the device** (Jetson / OrangePI / operator-PC), one instance per device, and shares the local PostgreSQL with its sibling edge services.
**System boundaries**:
- **Inside the system**: the 6 components (`01_vehicle_catalog`, `02_mission_planning`, `04_persistence`, `05_identity`, `06_http_conventions`, `07_host`), their HTTP surface, and the migrator that owns 4 PostgreSQL tables (`vehicles`, `missions`, `waypoints`, `map_objects`).
- **Outside the system**: the central `admin` service (mints JWTs); the React `ui` (consumer); the `autopilot` service (writes `map_objects` via the same DB); the `annotations` service (owns `media` + `annotations` tables); the detection pipeline (owns `detection`); the new `gps-denied` service (owns `orthophotos` + `gps_corrections` — out of this repo as of B7).
**External systems**:
| System | Integration Type | Direction | Purpose |
|--------|------------------|-----------|---------|
| `admin` (.NET, central) | JWT (HMAC shared secret) | Inbound (validation only) | Issues bearer tokens that this service validates locally; no network call back |
| Operator UI (React, edge) | REST (JSON over HTTP) | Inbound | All vehicle / mission / waypoint CRUD |
| `autopilot` (edge) | Shared DB (PostgreSQL on the same device) | Bidirectional | `autopilot` writes `map_objects` (this service owns the schema and cascade-deletes them); `autopilot` reads `missions` + `waypoints` to drive the vehicle |
| `annotations` (edge) | Shared DB | Outbound delete | `missions` cascade-deletes from `media` + `annotations` on mission/waypoint delete; `annotations` owns the schema |
| Detection pipeline (edge) | Shared DB | Outbound delete | Same pattern — `missions` cascade-deletes `detection` rows; pipeline owns the schema |
| `gps-denied` (separate edge service) | Shared DB (loose ref by GUID) | None at runtime | `gps-denied` rows reference `mission_id` / `waypoint_id` as plain GUIDs; no inbound HTTP call into `missions` and no outbound call from `missions` to `gps-denied` (decoupled by design after B7) |
| `postgres-local` (PostgreSQL 16+) | TCP | Outbound | Sole datastore. Shared with every other edge service on the same device |
## 2. Technology Stack
| Layer | Technology | Version | Rationale |
|-------|------------|---------|-----------|
| Language | C# | net10.0 | Suite-wide convention for backend services (per `../../suite/_docs/_repo-config.yaml`) |
| Web framework | ASP.NET Core (`Microsoft.NET.Sdk.Web`) | net10.0 | Built-in DI, middleware pipeline, attribute routing, JWT bearer auth |
| Data access | linq2db | 6.2.0 | Suite-wide ORM choice; explicit SQL escape hatch + attribute mapping; works well with the manual cascade pattern |
| Database driver | Npgsql | 10.0.2 | PostgreSQL native protocol driver |
| Schema bootstrap | linq2db raw `Execute` (`CREATE TABLE IF NOT EXISTS`) | — | Forward-only-additive; one `DROP TABLE IF EXISTS orthophotos / gps_corrections` block in B9 |
| Auth | `Microsoft.AspNetCore.Authentication.JwtBearer` | 10.0.5 | JWT bearer with HS256 (shared secret with `admin`); local validation, no callback to issuer |
| API docs | `Swashbuckle.AspNetCore` | 10.1.5 | Swagger UI + JSON spec (mounted unconditionally — see ADR-005) |
| HTTP error envelope | Custom `ErrorHandlingMiddleware` | — | Maps `KeyNotFoundException`/`ArgumentException`/`InvalidOperationException` → 404/400/409 (see ADR-002 and component `06_http_conventions` Caveats for divergences from suite spec) |
| Container | `mcr.microsoft.com/dotnet/aspnet:10.0` (multi-arch SDK build) | 10.0 | Matches edge target architectures (ARM64 dominant; AMD64 used for operator-PC) |
| CI | Woodpecker (`.woodpecker/build-arm.yml`) | — | Single docker-build-and-push job triggered on `[dev, stage, main]`; suite-standard runner |
| Hosting | Docker compose on each edge device | — | Service runs alongside `annotations`, `detection`, `autopilot`, `gps-denied`, `ui`, `postgres-local` per `../../suite/_docs/00_top_level_architecture.md` |
| Tests | **None present** | — | Tracked in `../../suite/_docs/_process_leftovers/2026-04-22_ci-unit-test-lane-missing-projects.md`; will be filled by the autodev BUILD pipeline (Steps 3 → 6) |
**Key constraints from discovery**:
- **No `src/` directory** — the .NET project sits at the repo root (`Azaion.Missions.csproj`, `Program.cs`). `coderule.mdc` says "follow the established directory structure", and the established structure here has no `src/`. This shape persists post-rename.
- **No per-component csproj** — there is one project, effectively one root namespace (`Azaion.Missions.*` post-B5). Components are logical groupings, not compilation units. Cross-component dependencies are checked by convention (per `module-layout.md` § Allowed Dependencies), not by compiler.
- **Layer-organized, not feature-organized layout** — `Controllers/`, `Services/`, `DTOs/`, `Enums/`, `Auth/`, `Middleware/`, `Database/` at the root. Component `Owns` globs are file-by-file lists across multiple top-level directories. See `module-layout.md` § Layout Rules.
- **One PostgreSQL shared with all edge services** — the per-service ownership pattern is the load-bearing convention (`../../suite/_docs/00_top_level_architecture.md` § Database Topology).
- **No automated tests** — every change today is human-reviewed only. Adding a `tests/Azaion.Missions.Tests/` sibling project is on the autodev backlog (Steps 57 of existing-code flow).
## 3. Deployment Model
**Environments**: Development (local `dotnet run` + local PostgreSQL), edge production (Docker compose on each device).
**Infrastructure**:
- **On-prem only** — every Azaion edge deployment is on customer-owned hardware (Jetson Orin / OrangePI / operator-PC). No managed cloud.
- **Container orchestration**: plain `docker compose` per device (see `../../suite/_infra/_compose/`). No Kubernetes.
- **Scaling**: vertical only — exactly one instance of `missions` per edge device, sized to the device. Horizontal scale-out of edge services is explicitly out of scope (each device is its own deployment).
- **Watchtower** restarts the container if it crashes; `flight-gate` (per `../../suite/_docs/00_top_level_architecture.md`) prevents container restart mid-mission.
**Image / port wiring** (post-B10):
- Image tag: `${REGISTRY_HOST}/azaion/missions:${BRANCH}-arm` (was `azaion/flights:*-arm` pre-B10).
- Container `EXPOSE 8080`; edge compose maps host port `5002:8080`.
- Entrypoint: `dotnet Azaion.Missions.dll` (was `Azaion.Flights.dll` pre-B5).
- Multi-arch build: `--platform=$BUILDPLATFORM`, `dotnet publish --os linux --arch $arch` so a single Dockerfile produces both ARM64 and AMD64 image variants.
**Environment-specific configuration**:
| Config | Development | Edge production |
|--------|-------------|-----------------|
| `DATABASE_URL` | `Host=localhost;Database=azaion;Username=postgres;Password=changeme` (hardcoded fallback) | `postgresql://postgres:${PG_LOCAL_PASSWORD}@postgres-local/azaion` (compose env) |
| `JWT_SECRET` | `development-secret-key-min-32-chars!!` (hardcoded fallback) | Provisioned secret shared across `admin` + every backend service on the device |
| Logging | Console / Debug (ASP.NET Core defaults) | Console only (no Serilog / structured logging configured today) |
| Swagger | enabled | enabled (NOT gated on `IsDevelopment()` — see ADR-005) |
| CORS | `AllowAnyOrigin/Method/Header` | `AllowAnyOrigin/Method/Header` (no environment override; assumed safe behind suite reverse proxy) |
| Migrator | runs at process start | runs at process start (idempotent `IF NOT EXISTS` + the one B9 `DROP TABLE IF EXISTS` block for legacy GPS-Denied tables on previously-deployed devices) |
For containerization details, CI pipeline structure, and observability, see `_docs/02_document/deployment/`.
## 4. Data Model Overview
> Detailed entity column shapes live in `_docs/02_document/modules/entities.md`. Detailed cross-service ownership lives in `_docs/02_document/data_model.md`.
**Core entities** (post-B7 shape — 7 entity files, 4 owned tables + 3 borrowed read-only stubs):
| Entity | Description | Owned by component |
|--------|-------------|--------------------|
| `Vehicle` | Operator-managed inventory of mission-capable assets (Plane / Copter / UGV / GuidedMissile). 1 default at most by spec; code currently enforces "exactly one default" (see B12) | `01_vehicle_catalog` (logically); table schema in `04_persistence` |
| `Mission` | Planned mission; FK to a `Vehicle` | `02_mission_planning` (logically); table schema in `04_persistence` |
| `Waypoint` | Ordered geo-point inside a `Mission`; FK to `Mission` | `02_mission_planning` (logically); table schema in `04_persistence` |
| `MapObject` | H3-indexed detection projection written by `autopilot`; FK to `Mission` | `04_persistence` owns the schema; `autopilot` is the writer; this service cascade-deletes |
| `Media` | Borrowed read-only stub. Owned by `annotations`. Cascade-delete only | `04_persistence` declares the entity for ITable access; schema owned by `annotations` |
| `Annotation` | Borrowed read-only stub. Owned by `annotations`. Cascade-delete only | Same as `Media` |
| `Detection` | Borrowed read-only stub. Owned by detection pipeline. Cascade-delete only | Schema owned by detection pipeline; this service has cascade-delete responsibility only |
**Removed in B7+B9**: `Orthophoto` and `GpsCorrection` entities + tables. Now owned by the separate `gps-denied` service.
**Key relationships**:
- `Vehicle (1) ── (0..N) Mission``mission.vehicle_id → vehicle.id`. Existence-checked on `MissionService.CreateMission` / `UpdateMission` (the FK constraint is the safety net).
- `Mission (1) ── (0..N) Waypoint``waypoint.mission_id → mission.id`.
- `Mission (1) ── (0..N) MapObject``map_object.mission_id → mission.id`. Written by `autopilot`; cascade-deleted by `missions`.
- `Waypoint (1) ── (0..N) Media` (cross-service FK to `annotations`-owned table) — cascade-deleted by `missions`.
- `Media (1) ── (0..N) Annotation` (intra-`annotations` FK) — cascade-deleted by `missions` while walking the dependency graph.
- `Annotation (1) ── (0..N) Detection` (intra-detection FK) — cascade-deleted by `missions` while walking the dependency graph.
**No FK to `gps-denied` tables**`orthophotos` / `gps_corrections` reference `mission_id` and `waypoint_id` as plain GUIDs in the `gps-denied` service's own tables. Cleanup of those rows is `gps-denied`'s own concern; this service does NOT cascade into them.
**Data flow summary**:
- `Operator UI → missions (HTTP)` — vehicle + mission + waypoint CRUD; the dominant inbound flow.
- `admin → operator UI → missions (JWT)` — admin mints token; UI carries it to every backend; this service validates locally (HS256, shared secret).
- `autopilot → missions (DB read)``autopilot` reads `missions` + `waypoints` to drive the vehicle.
- `autopilot → missions (DB write)``autopilot` writes `map_objects`; this service owns the table schema and cascade-deletes them.
- `missions → annotations + detection (DB delete)` — cascade-delete walk during mission/waypoint delete; tears down `media`, `annotations`, `detection` rows in dependency order.
## 5. Integration Points
### Internal Communication
This service is a single .NET process. Components communicate via direct C# calls registered in DI (`07_host`). There is no in-process message queue, no RPC, no event bus.
| From | To | Protocol | Pattern | Notes |
|------|----|----------|---------|-------|
| `07_host` | `04_persistence`, `05_identity`, `06_http_conventions` | DI registration | Composition root | Wired once at startup |
| `01_vehicle_catalog` (controller → service) | `04_persistence` (`AppDataConnection`) | Direct C# call | Active-record over `ITable<Vehicle>` | Per-request scoped DB connection |
| `02_mission_planning` (controllers → services) | `04_persistence` (`AppDataConnection`) | Direct C# call | Active-record over `ITable<Mission>`, `ITable<Waypoint>`, plus cascade delete touching `MapObject`, `Media`, `Annotation`, `Detection` | Per-request scoped DB connection. **No transaction wraps cascade delete** — see `02_mission_planning` Caveats #1 |
| `02_mission_planning` (`MissionService`) | `01_vehicle_catalog` (existence) | Direct DB read against `vehicles` | Existence check | Cross-component, but reads via the shared `AppDataConnection`; no service-to-service call |
| `01_vehicle_catalog`, `02_mission_planning` (controllers) | `05_identity` (`"FL"` policy) | ASP.NET Core `[Authorize(Policy = "FL")]` attribute | Pipeline check | String-typed policy reference (see `module-layout.md` § Verification Needed #4) |
| `02_mission_planning` (`MissionService.GetMissions`) | `06_http_conventions` (`PaginatedResponse<T>`) | Direct C# type | DTO | Sole consumer of the paginated envelope |
| Every controller exception | `06_http_conventions` (`ErrorHandlingMiddleware`) | Pipeline interceptor | Exception → status mapping | Middleware is registered FIRST so it wraps everything |
### External Integrations
| External system | Protocol | Auth | Rate limits | Failure mode |
|-----------------|----------|------|-------------|--------------|
| Operator UI | REST (JSON over HTTP) | JWT bearer | None enforced | Standard HTTP error envelope (see ADR-002 for the suite-spec divergence still in code) |
| `admin` (token issuance) | None at runtime — this service validates tokens locally | Shared HMAC secret (`JWT_SECRET`) | N/A | Rejected token → `401`. No network call to `admin`, so `admin` outage does NOT take this service down (until issued tokens expire) |
| `postgres-local` | PostgreSQL wire protocol via Npgsql | Username + password (`DATABASE_URL`) | Connection pool default (Npgsql) | Connection failure → `KeyNotFoundException` cannot fire (different exception type) → middleware fallthrough → 500. Migrator failure at startup crashes the process; Watchtower restarts the container |
| `autopilot` (DB-mediated) | Shared `postgres-local` | Same DB credentials | N/A | If `autopilot` writes a `map_object` referencing a deleted mission, the FK constraint rejects the insert. If a mission delete races with an `autopilot` write, the cascade may leave one row of `map_objects` that the next mission delete would reject — small race window, no data corruption |
| `annotations`, detection pipeline (DB-mediated, schema borrowing) | Shared `postgres-local` | Same DB credentials | N/A | If `annotations` is absent at deploy time, the cascade walks `media` / `annotations` and gets `relation does not exist` → 500. In standard edge deployment all services are present (suite compose stack) — see `02_mission_planning` Caveats #6 |
| `gps-denied` (post-B7) | None — no runtime coupling. `gps-denied` owns its own tables and references `mission_id` / `waypoint_id` as plain GUIDs | N/A | N/A | Decoupled by design |
## 6. Non-Functional Requirements
> Numbers below are observable from code + Dockerfile + Woodpecker; the spec (`../../suite/_docs/02_missions.md`) does not state explicit SLOs. Where targets are inferred, that is called out.
| Requirement | Target | Measurement | Priority |
|-------------|--------|-------------|----------|
| Availability | Best-effort per-device; no multi-instance HA per device | One container per device; restart-on-crash via Watchtower; `flight-gate` prevents restart mid-mission per `../../suite/_docs/00_top_level_architecture.md` | High (per-device) |
| Latency (p95) | **Not specified.** Code uses synchronous LINQ-to-SQL with one DB round-trip per operation; cascade delete has up to 7 sequential SELECTs/DELETEs. On a local PostgreSQL on the same device this is single-digit ms typical | `/health` and CRUD endpoints; no explicit latency budget | Medium (inferred) |
| Throughput | **Not specified.** Edge deployment is one operator + one or two background consumers (`autopilot`, `ui`); load is operator-paced not load-tested | — | Low (inferred) |
| Data retention | No retention policy in this service. Data persists in `postgres-local` until manually deleted via the API or device wipe | — | — |
| Recovery (RPO/RTO) | RPO = device-local backup cadence (suite-level concern, not this service); RTO ≈ container restart time (~10s) | Watchtower restart on crash | Medium |
| Scalability | One instance per edge device; horizontal scale-out NOT supported | — | — (out of scope) |
| Cascade delete atomicity | **Currently violated**`MissionService.DeleteMission` and `WaypointService.DeleteWaypoint` are NOT wrapped in a transaction (see `02_mission_planning` Caveats #1). Partial failure leaves orphan rows in `media` / `annotations` / `detection` / `map_objects`. Fix is one-line (`db.BeginTransactionAsync`) | Carry-forward improvement | High (data integrity) |
| API spec conformance | **Currently divergent** on entity/DTO wire shape (PascalCase vs spec camelCase) and on error envelope's missing `errors` field; the unused `ErrorResponse` DTO has wrong `Errors` shape (see ADR-002). Note: error envelope is already camelCase on case (accidental match) | Manual diff against `../../suite/_docs/00_top_level_architecture.md` § Error Response Format + § Pagination | High (cross-service contract) |
| Health endpoint | `GET /health` returns `{ status: "healthy" }` in <10ms | `Program.cs` `MapGet` | High (used by container orchestration) |
## 7. Security Architecture
**Authentication**: JWT bearer (HS256). Tokens are minted by the central `admin` service and validated locally by `05_identity` using a shared HMAC secret (`JWT_SECRET`). This service NEVER calls back to `admin`; rotation of the secret requires coordinated redeploy across every backend service that shares the secret. Per the CMMC L2 scorecard (`../../suite/_docs/05_security/cmmc_l2_scorecard.md` row 3), `iss` / `aud` validation is currently disabled; this is a known finding tracked at the suite level under AZ-487 / AZ-494 (out of this Epic's scope).
**Authorization**: Single named policy `"FL"`, gated by a `permissions` claim value. Every controller route in `01_vehicle_catalog` and `02_mission_planning` carries `[Authorize(Policy = "FL")]`. The role → permission matrix lives in `../../suite/_docs/00_roles_permissions.md`. Note: the policy code `"FL"` carries the legacy "Flight" name even after the service rename to `missions`; renaming the permission code is a fleet-wide auth change (would invalidate every issued token until new ones are minted) and is **NOT** in this Epic's scope. Tracked as a TODO in `../../suite/_docs/00_roles_permissions.md`.
**Data protection**:
- **At rest**: PostgreSQL on-disk encryption is the device-level concern (suite-level, not this service). This service does not encrypt data at the column level.
- **In transit**: TLS termination is the reverse proxy's responsibility. This service does NOT enforce HTTPS redirection. The container `EXPOSE 8080` is plain HTTP; the upstream reverse proxy adds TLS.
- **Secrets management**: `DATABASE_URL` and `JWT_SECRET` are env vars. The `Program.cs` hardcoded fallbacks (`development-secret-key-min-32-chars!!`, `Password=changeme`) are dev-only and MUST be overridden in production. There is **no runtime gate** that blocks startup with the dev fallback in production — see ADR-005.
**Audit logging**: None at the application level. The only structured log emitted by app code is `06_http_conventions`' middleware `LogError(ex, "Unhandled exception")` for unhandled 500s. There is no per-request audit trail, no correlation ID, and no per-user attribution (the JWT's user-id claim is not consumed — see `05_identity` Caveats #2).
**Input validation**: None. No `[Required]` attributes, no range checks. Empty `Name`, negative `BatteryCapacity`, invalid enum int values are accepted on input. Carry-forward improvement; not in this Epic's scope.
**CORS**: `AllowAnyOrigin/Method/Header` in all environments. Spec does not mandate a CORS policy — likely safe behind the suite's edge reverse proxy (per `../../suite/_docs/00_top_level_architecture.md`) but worth confirming on first production deployment.
## 8. Key Architectural Decisions
> ADR numbering reflects what is implemented today (post-rename, post-B7). Items called out as "currently divergent" are intentional carry-forward — they are implemented choices that diverge from the suite spec; tightening them is suite-level work, not part of this Epic.
### ADR-001: One PostgreSQL per edge device, shared by all edge services
**Context**: Each edge device runs ~6 backend services (this one + `annotations`, detection, `autopilot`, `gps-denied`, plus the React `ui`). Each service needs persistent storage; running ~6 separate Postgres instances per device is operationally heavy.
**Decision**: Run ONE `postgres-local` per device. Every service connects to it; every service migrates only the tables it owns (this service owns `vehicles`, `missions`, `waypoints`, `map_objects` post-B7+B9). Cross-service reads / cascade deletes happen through ITable accessors against the shared schema.
**Alternatives considered**:
1. **One Postgres per service** — rejected: 6× the operational overhead per device for no real isolation gain (services run on the same OS anyway).
2. **SQLite per service** — rejected: cross-service queries (cascade delete walking from `mission` to `media` to `annotation` to `detection`) require a single transactional database; SQLite-per-service would require a coordination layer.
**Consequences**:
- Cross-service cascade-delete is physically possible and atomic *within one DB connection* (the transaction-wrap is a one-line carry-forward — see ADR-006).
- Schema ownership boundary is enforced by **convention**, not by access control. Any service could write to any table; the rule "only owners write" is upheld by code review.
- If `annotations` is absent from a deployment, this service's cascade-delete fails on `relation does not exist`. Standard edge compose includes all services; this is acceptable.
### ADR-002: PascalCase wire shape on entity bodies (currently divergent from suite spec)
**Context**: Spec (`../../suite/_docs/00_top_level_architecture.md` § Error Response Format + § Pagination) mandates camelCase JSON across all .NET services. Code today emits PascalCase for entity / DTO responses (`Vehicle`, `Mission`, `Waypoint`, `PaginatedResponse<Mission>`) via System.Text.Json defaults — entity property names are PascalCase and no `JsonNamingPolicy.CamelCase` is configured. **Exception (accidental match)**: the global error envelope IS already camelCase, because `ErrorHandlingMiddleware` writes an anonymous object literal `new { statusCode = ..., message }` whose property names are lowercase-first by construction; `System.Text.Json` preserves them as-is.
**Decision (current, carry-forward)**: Keep PascalCase entity bodies until a coordinated suite-wide camelCase migration. Adding `JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase` would flip every endpoint's wire shape simultaneously; the UI and `autopilot` consumers would need to be updated in lock-step.
**Alternatives considered**:
1. **Fix unilaterally now** — rejected for this Epic: would break the UI without a coordinated cutover.
2. **Per-route override** — rejected: all-or-nothing is the cleaner cutover.
**Consequences**:
- Entity/DTO HTTP responses do NOT match the suite spec on case style.
- The error envelope DOES match spec on case (camelCase) but still misses the `errors` field; the `ErrorResponse` DTO is dead on the wire (middleware writes the anonymous object instead) and its `Errors` field shape (`List<string>?`) doesn't match spec (`object?` keyed by field name) — both carry forward until the migration.
### ADR-003: Manual cascade-delete in code, not `ON DELETE CASCADE` in schema
**Context**: Mission deletion has to clean up rows across multiple tables, some of which are owned by other services (`media` / `annotations` / `detection`). Schema-level `ON DELETE CASCADE` would force the foreign service's schema to encode this service's lifecycle.
**Decision**: This service owns the cascade walk. `MissionService.DeleteMission` deletes in dependency order: `map_objects` → resolve `waypoint_ids` → resolve `media_ids` and `annotation_ids``detection``annotations``media``waypoints``missions`.
**Alternatives considered**:
1. **`ON DELETE CASCADE` at the schema level** — rejected: would require the `annotations` service to encode this service's domain in its own migration. Schema becomes coupled to consumer.
2. **Soft-delete + tombstone everywhere** — rejected: read paths everywhere would have to filter; the spec does not require it.
**Consequences**:
- The cascade walk lives in one place (`MissionService.DeleteMission` + `WaypointService.DeleteWaypoint`).
- It is **not transaction-wrapped today** (see ADR-006) — a one-line fix carried forward.
- If `gps-denied` ever adds rows that need cleanup on mission delete, that's `gps-denied`'s concern (it owns the tables and the lifecycle) — this service does not extend its cascade.
### ADR-004: Schema bootstrap via `CREATE TABLE IF NOT EXISTS` (no migration tool)
**Context**: Edge deployments are restart-driven (Watchtower picks up new images); each container start runs the migrator. A heavy migration tool (Flyway, EF Core migrations) adds dependencies and complexity.
**Decision**: `DatabaseMigrator.Migrate` runs additive `CREATE TABLE IF NOT EXISTS` + `CREATE INDEX IF NOT EXISTS` for the 4 owned tables. The B9 ticket adds a one-shot `DROP TABLE IF EXISTS orthophotos; DROP TABLE IF EXISTS gps_corrections;` block for fielded devices that previously ran the legacy schema.
**Alternatives considered**:
1. **EF Core / Flyway** — rejected: adds a build dependency and a state table for what is currently a 4-table schema with no column drops or type changes.
2. **External SQL scripts** — rejected: harder to keep aligned with code-side entity changes; deployment becomes two-step.
**Consequences**:
- Column drops / type changes / constraint changes will require manual SQL or a future migration tool. The B9 `DROP` is the one explicit destructive step in the migrator's history.
- No version table; the migrator is idempotent and runs every startup.
- Acceptable today; will become a real problem if the schema starts evolving frequently.
### ADR-005: Swagger + dev fallbacks not gated on `IsDevelopment()`
**Context**: ASP.NET Core's idiomatic pattern gates Swagger UI and dev-only convenience features on `app.Environment.IsDevelopment()`. Today, this service mounts Swagger unconditionally and uses hardcoded dev fallbacks for `JWT_SECRET` / `DATABASE_URL` if env vars are unset.
**Decision (current, carry-forward)**: Leave both unconditional today. Swagger UI is useful on edge devices for one-off operator debugging through the local network. The hardcoded dev fallbacks are a known foot-gun (a misconfigured production deploy will silently use the well-known secret) but they are intentional during the rename phase to keep `dotnet run` working zero-config.
**Alternatives considered**:
1. **Gate both on `IsDevelopment()`** — preferred long-term; out of this Epic.
2. **Fail-fast at startup if `JWT_SECRET` is unset** — preferred long-term; out of this Epic.
**Consequences**:
- Swagger UI is exposed on every deployment. The reverse proxy may or may not whitelist it; verify on first production rollout.
- A production deployment without `JWT_SECRET` set will silently boot with the well-known dev secret. This is a security finding tracked at the suite level (see CMMC L2 row 3).
### ADR-006: Cascade-delete is NOT transaction-wrapped (carry-forward)
**Context**: `MissionService.DeleteMission` and `WaypointService.DeleteWaypoint` issue 47 sequential `DELETE` statements across tables. Without a transaction, partial failure leaves orphan rows.
**Decision (current, carry-forward)**: Today the cascade runs autocommit-per-statement. Wrapping in `db.BeginTransactionAsync()` is one extra line and will land as part of the broader testability / refactor pass after the rename Epic.
**Alternatives considered**:
1. **Wrap now in B6** — possible; B6 is a rename, not a behavior change. The transaction wrap is a separate one-line concern that can either ride along (cheap) or land standalone.
2. **Saga / outbox pattern** — overkill for an in-process, one-DB cascade.
**Consequences**:
- Partial cascade failure leaves orphan rows in `media` / `annotations` / `detection` / `map_objects` / `waypoints`. The next mission delete or `autopilot` write may surface the inconsistency as an FK violation.
- **Recommended**: include the transaction wrap when B6 lands; it is a one-line change that materially raises the data-integrity floor.
### ADR-007: GPS-Denied moved out of this repo (B7 + B9)
**Context**: The pre-rename `flights` repo had a `03_gps_denied` component covering orthophoto upload + live-GPS / GPS-correction endpoints. Per `../../suite/_docs/11_gps_denied.md` and the rename plan, GPS-Denied is its own domain (orthorectification of satellite imagery; correction of GPS drift in denied environments) and does not belong inside the mission-planning service.
**Decision**: Delete `Database/Entities/Orthophoto.cs`, `Database/Entities/GpsCorrection.cs`, the corresponding DTOs/controllers/services, the `"GPS"` policy, and the cascade branches that referenced `orthophotos` / `gps_corrections`. Add a one-shot `DROP TABLE IF EXISTS` block to the migrator for fielded devices.
**Alternatives considered**:
1. **Keep GPS-Denied in this repo, behind a feature flag** — rejected: the new `gps-denied` service has different scaling and deployment concerns (heavier disk for orthos, separate update cadence).
2. **Leave the schema, drop only the API** — rejected: leaves dead tables on every device with no ownership; cleanup later would be harder.
**Consequences**:
- 9 entity files → 7 entity files. 6 owned tables → 4 owned tables.
- `MissionService.DeleteMission` cascade chain shrinks (no `orthophotos` / `gps_corrections` branch). One less foot-gun.
- `gps-denied` references `mission_id` / `waypoint_id` as plain GUIDs in its own tables. **No runtime coupling** between the two services — `gps-denied` is responsible for cleaning up its own rows when missions are deleted (its own concern, its own decision).
### ADR-008: One project, one root namespace (no per-component csproj)
**Context**: Some .NET solutions split each component into its own csproj for compile-time enforcement of "no upward dependencies". This service has 6 logical components but one csproj.
**Decision**: Keep one project (`Azaion.Missions.csproj` post-B5), one effective root namespace (`Azaion.Missions.*`). Layering rules in `module-layout.md` § Allowed Dependencies are enforced by **convention** (and by the autodev `code-review` Phase 7), not by the compiler.
**Alternatives considered**:
1. **Per-component csproj** — rejected for this codebase: 6 csprojs in a service this small has more solution-management overhead than it has value. Cross-component types are referenced directly, not through public APIs.
2. **Shared `Common` project + per-component projects** — rejected: same overhead as #1, plus the cross-cutting concerns (`Auth/`, `Middleware/`) are tiny and don't warrant their own DLL.
**Consequences**:
- A typo in an import won't be caught by the compiler — code review + the layering table in `module-layout.md` are the safety net.
- Solution remains easy for one engineer to navigate.
- If the service ever splits in two, the rename to per-project structure would be a separate refactor (not part of this Epic).