refactor: enhance JWT authentication and CORS configuration

Updated JWT authentication to use configuration values instead of hardcoded secrets, improving security and flexibility. Enhanced CORS policy to conditionally allow origins based on configuration settings, with logging for permissive defaults. Updated README to reflect project renaming and clarify service context.
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-14 19:48:25 +03:00
parent 2fe394d732
commit 7025f4d075
74 changed files with 8494 additions and 19 deletions
@@ -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.