# Tile identity → UUIDv5 + integer UPSERT (foundation)

**Task**: AZ-503_tile_identity_uuidv5_bulk_list
**Name**: Tile identity → UUIDv5 + integer UPSERT (foundation)
**Description**: This task is the **foundation half** of the original AZ-503 spec. It migrates tile identity to deterministic UUIDv5, adds the `flight_id` / `location_hash` / `content_sha256` / `legacy_id` columns, switches the UPSERT conflict key to integer-only with per-flight separation, plumbs `FlightId` through `UavTileMetadata` + `UavTileUploadHandler`, and migrates the on-disk UAV layout to per-flight directories. The original spec also covered the bulk-inventory endpoint, HTTP/2 enablement, leaflet covering index, and Leaflet hot-path rewrite — those are now in **AZ-505** ("Tile inventory endpoint + HTTP/2 + leaflet covering index") and consume the columns this task lands.
**Complexity**: 3 points
**Dependencies**: AZ-484 (UPSERT-per-source + AZ-484 selection rule — done; this task supersedes the UPSERT conflict-key portion)
**Blocks**: AZ-505 (inventory endpoint + HTTP/2 + leaflet covering index) — AZ-505 cannot start until the `location_hash` and `flight_id` columns land.
**Component**: SatelliteProvider.Common + SatelliteProvider.DataAccess + SatelliteProvider.Services.TileDownloader
**Tracker**: AZ-503
**Epic**: AZ-483 — Multi-source tile storage + UAV upload (Layer 2)

## Scope split note (cycle 5 /autodev Step 10 batch 2)

During /autodev resumption, the spec was reconciled against the current codebase and three contradictions surfaced:

1. **`flight_id` column does not exist** on the `tiles` table; the original UPSERT key `COALESCE(flight_id, ...)` assumed it did.
2. **`UavTileMetadata.FlightId` field does not exist** in the DTO; AC-3 (multi-flight rows coexist) and AC-11 (per-flight on-disk separation) cannot pass without adding it + plumbing.
3. **`voting_status` column does not exist** (and is explicitly out of scope — voting is a separate task); the original AC-10 query referenced it.

Combined work measured at ~5 SP. User picked Option C: split into AZ-503-foundation (this task) + AZ-505 (inventory endpoint + HTTP/2 + leaflet covering index). AZ-505 is `Blocks`-linked and waits for this task's columns to land. The original AC numbering is preserved; ACs deferred to AZ-505 are marked **[→ AZ-505]** below.

## Origin

Cross-workspace surface from `gps-denied-onboard` `_docs/_process_leftovers/2026-05-12_tile-schema-scenario-analysis.md`. The onboard repo's `AZ-304` C6 Postgres schema is being designed with `location_hash` + `content_sha256` columns and a deterministic `id`; this satellite-provider task is the parent-suite counterpart so both sides of the wire agree on tile identity semantics.

Related: `gps-denied-onboard` `_docs/_process_leftovers/2026-05-09_satellite-provider-design-tasks.md` Design Task #2 (multi-flight trust / voting layer) is the downstream consumer of this task's `flight_id`-aware UPSERT and `content_sha256`.

## Problem

Three concrete issues in the current code:

1. **Random tile id** — `SatelliteProvider.Services.TileDownloader/TileService.cs:149` and `UavTileUploadHandler.cs:160` use `Id = Guid.NewGuid()`. The `id` is opaque, non-deterministic, and useless as a content/location handle. Onboard cannot pre-compute or compare ids before round-tripping to the DB.

2. **Float-based UPSERT collapses multi-flight evidence** — `TileRepository.InsertAsync` line 124: `ON CONFLICT (latitude, longitude, tile_zoom, tile_size_meters, source) DO UPDATE SET file_path = EXCLUDED.file_path, ...`. Problems:
   - `latitude` / `longitude` are `double precision`; Postgres conflict detection requires bit-identical floats. Re-uploads of the same tile computed from independently-rounded center coords can miss the conflict and create duplicate rows. AZ-484's `DISTINCT ON` read-side fix papers over the duplicate but does not prevent it.
   - The conflict key omits `flight_id`. When two flights upload `source='uav'` for the same cell, `DO UPDATE` overwrites Flight A with Flight B. **D-PROJ-2 voting (Design Task #2 from the 2026-05-09 leftover) needs both rows alive.**

3. **No bulk-list endpoint for pre-flight provisioning** — onboard `TileDownloader` (`gps-denied-onboard` AZ-316) calls `GET /api/satellite/tiles?bbox=...&zoom=...&list-only=true` to size and enumerate a pre-flight cache build. **This endpoint does not exist** in `SatelliteProvider.Api/Program.cs`. The closest is `GetTilesByRegionAsync` (private, lat/lon-meters input, no HTTP surface) and `GET /api/satellite/tiles/latlon` (single tile). Operators today cannot pre-size a cache build over the bbox the mission planner produces.

4. **No content digest** — there is no `content_sha256` column. Same JPEG re-uploaded under a different `source` or `flight_id` is indistinguishable from a re-encode.

## Outcome

- Tile id becomes deterministic: `id = uuidv5(TILE_NAMESPACE, "{z}/{x}/{y}/{source}/{flight_id or '00000000-0000-0000-0000-000000000000'}")`. Same inputs always produce the same id; idempotent inserts no longer require a "did this row exist?" pre-check.
- `location_hash uuid NOT NULL` column: `uuidv5(TILE_NAMESPACE, "{z}/{x}/{y}")`. Drives Scenario 1 (UI Leaflet `/tiles/{z}/{x}/{y}`) lookup as a single hash-index probe, and Scenario 6 (voting) as a single cell-bag fetch.
- `content_sha256 bytea NOT NULL` column: SHA-256 of the JPEG body, computed at insert time. Enables dedup detection ("Flight B uploaded a byte-identical tile to Flight A — flag for inspection") and integrity checks on the read path.
- UPSERT conflict key becomes:
  ```
  ON CONFLICT (zoom_level, tile_x, tile_y, tile_size_meters, source,
               COALESCE(flight_id, '00000000-0000-0000-0000-000000000000'::uuid))
  DO UPDATE SET file_path = EXCLUDED.file_path, captured_at = EXCLUDED.captured_at, updated_at = EXCLUDED.updated_at
  ```
  Integer-only equality, per-flight separation. (Note: requires `zoom_level` column — the current schema uses `tile_zoom`; either rename or use `tile_zoom` consistently — this task keeps the existing column name `tile_zoom` and adjusts the onboard-side spec to match.)
- **New endpoint: `POST /api/satellite/tiles/inventory`** (preferred over the originally-proposed `GET /api/satellite/tiles?bbox=...`). Body is a list of `(z, x, y)` coords (or pre-computed `location_hash` UUIDs); response is one entry per input. Justification: the onboard side already has the deterministic slippy-tile math (`helpers/wgs_converter.py.latlon_to_tile_xy`, identical to C# `GeoUtils.WorldToTilePos`); making the server re-enumerate the bbox is wasted work. POST inventory is also the natural batched-existence-check shape — single round-trip, indexed lookup per row.
  ```
  POST /api/satellite/tiles/inventory
  Body: { "tiles": [ { "z": 18, "x": 12345, "y": 23456 }, ... ] }
        // OR { "location_hashes": [ "uuid-v5", ... ] }
  Response: {
    "results": [
      {
        "tile_x": 12345, "tile_y": 23456, "tile_zoom": 18, "location_hash": "uuid-v5...",
        "present": true,
        "id": "uuid-v5...", "captured_at": "...", "resolution_m_per_px": 0.3,
        "estimated_bytes": 42017, "source": "google_maps", "flight_id": null
      },
      { "tile_x": 12346, "tile_y": 23456, "tile_zoom": 18, "location_hash": "uuid-v5...", "present": false }
    ]
  }
  ```
  Server-side query (single round-trip):
  ```sql
  SELECT DISTINCT ON (location_hash)
         location_hash, id, captured_at, resolution_m_per_px, estimated_bytes, source, flight_id
  FROM tiles
  WHERE location_hash = ANY($1::uuid[])
    AND voting_status IN ('trusted', NULL)
  ORDER BY location_hash, captured_at DESC, updated_at DESC, id DESC;
  ```
- Required covering index for Leaflet hot path:
  ```sql
  CREATE INDEX tiles_leaflet_path
  ON tiles (location_hash, captured_at DESC, updated_at DESC, id DESC)
  INCLUDE (file_path, content_type, etag, voting_status);
  ```
  Leaflet `/tiles/{z}/{x}/{y}` becomes an index-only scan (no heap fetch when `voting_status='trusted'`):
  ```sql
  SELECT file_path FROM tiles
  WHERE location_hash = $1 AND voting_status IN ('trusted', NULL)
  ORDER BY captured_at DESC, updated_at DESC, id DESC LIMIT 1;
  ```
- Migration: additive — add `location_hash`, `content_sha256` as nullable, backfill in a single `UPDATE`, then set `NOT NULL`. Drop the old `UNIQUE (latitude, longitude, tile_zoom, tile_size_meters, source)` constraint and add the new integer-keyed one. Backfill `id` IS NOT trivial — see Risk 1.
- The UUIDv5 generator is implemented inline in `SatelliteProvider.Common/Utils/Uuidv5.cs` (RFC 9562 algorithm — 60 lines of C#, MD5 dropped, SHA-1 only). .NET 10 has no native `Guid.CreateVersion5` (only `CreateVersion7`); we do NOT add a 3rd-party dep for this.

## Scope

### Included (AZ-503-foundation)
- `SatelliteProvider.Common/Utils/Uuidv5.cs` — pure-C# RFC 9562 SHA-1 UUIDv5 implementation, unit-tested against the Python `uuid.uuid5` reference vectors (onboard side uses Python `uuid.uuid5`; both must produce byte-identical output for the same name + namespace). Defines `Uuidv5.TileNamespace` constant — the cross-repo shared UUID namespace.
- `SatelliteProvider.Common/DTO/UavTileMetadata.cs` — add `FlightId` (`Guid?`) field. Optional in the DTO (no FlightId is valid; UPSERT key uses zero-UUID coalesce). When provided, becomes part of identity.
- `SatelliteProvider.DataAccess/Migrations/014_AddTileIdentityColumns.sql` — additive migration:
  - `ADD COLUMN flight_id uuid NULL`
  - `ADD COLUMN location_hash uuid NULL` (set NOT NULL after backfill)
  - `ADD COLUMN content_sha256 bytea NULL` (set NOT NULL after backfill; existing rows backfilled with SHA-256 of `file_path` bytes if file exists else `'\x00...'` 32-byte zero digest — best-effort for legacy rows)
  - `ADD COLUMN legacy_id uuid NULL` populated from existing `id` (preserves random-id provenance for one cycle per Risk 1)
  - Backfill `location_hash = uuidv5(TILE_NAMESPACE, "{tile_zoom}/{tile_x}/{tile_y}")` — computed at migration time in SQL (`encode(digest(...), 'hex')`-based UUID assembly is too brittle in pure pg; instead, migration leaves `location_hash` nullable initially and the application backfills via a one-time startup task OR a separate script). Phase 1 approach: SQL backfill via plpgsql function. If too risky, drop to "set NULL, app re-computes on next read" path and document in migration comments.
  - Drop the AZ-484 unique index `idx_tiles_unique_location_source`
  - Add new unique index keyed on integers + `COALESCE(flight_id, '00000000-0000-0000-0000-000000000000'::uuid)`: `idx_tiles_unique_identity`
- `SatelliteProvider.DataAccess/Models/TileEntity.cs` — add `FlightId` (`Guid?`), `LocationHash` (`Guid`), `ContentSha256` (`byte[]`), `LegacyId` (`Guid?`) properties.
- `SatelliteProvider.DataAccess/Repositories/TileRepository.cs` — `InsertAsync` UPSERT rewritten with integer-only conflict key + `COALESCE(flight_id, ...)`; column list updated; `GetByTileCoordinatesAsync` selection rule preserved (no Leaflet rewrite here — that's AZ-505).
- `SatelliteProvider.Services.TileDownloader/TileService.cs` — `BuildTileEntity` computes deterministic `id = uuidv5(TILE_NAMESPACE, "{z}/{x}/{y}/google_maps/00000000-...")` and `location_hash = uuidv5(TILE_NAMESPACE, "{z}/{x}/{y}")`. No `Guid.NewGuid()`. Google Maps tiles have `flight_id = null`.
- `SatelliteProvider.Services.TileDownloader/UavTileUploadHandler.cs` — `PersistAsync`:
  - reads `metadata.FlightId` from the request body;
  - computes `id = uuidv5(TILE_NAMESPACE, "{z}/{x}/{y}/uav/{flight_id or 0000-...}")` and `location_hash = uuidv5(TILE_NAMESPACE, "{z}/{x}/{y}")`;
  - computes `content_sha256 = SHA256(imageBytes)`;
  - writes file to `./tiles/uav/{flight_id_or_'none'}/{z}/{x}/{y}.jpg` (when `flight_id IS NULL`, the path uses the literal `none` segment to keep the layout stable);
  - `BuildUavTileFilePath` signature gains an optional `Guid? flightId` parameter.
- `SatelliteProvider.Tests/UavTileFilePathTests.cs` — updated assertions for the per-flight path shape (covers `flightId` provided + `flightId = null` legacy branch).
- Integration test for multi-flight upload — confirms two `source='uav'` rows for the same `(z, x, y)` from different `flight_id`s coexist on disk (different paths) and in DB (different rows, same `location_hash`).
- Unit tests for `Uuidv5` against Python reference vectors (≥10 cases).

### Excluded (now in AZ-505)
- `POST /api/satellite/tiles/inventory` endpoint + DTOs.
- `tiles_leaflet_path` covering index.
- HTTP/2 / HTTP/3 enablement in Kestrel.
- Leaflet `GET /tiles/{z}/{x}/{y}` rewrite to use `location_hash`-keyed query (current `GetByTileCoordinatesAsync` path is preserved — Leaflet still works, just not yet against the covering index).
- nginx `http2 on;` directive in dev compose.

### Permanently excluded (per original spec rationale)
- Voting / trust-promotion layer — gps-denied-onboard Design Task #2; consumes `flight_id` from this task; not consumed here.
- Onboard companion auth (mTLS / signed payloads) — D-PROJ-2 Design Task #1.
- Column renames (`tile_zoom` → `zoom_level`) — `coderule.mdc` constraint.
- Per-flight key management — gps-denied-onboard AZ-318.
- Removing `latitude` / `longitude` columns — they stay as advisory center-of-tile data.
- Materialised `tile_current` pointer table — pre-optimisation rejected.
- Content-addressable storage layout — `content_sha256` is dedup *detection*, not dedup *storage*.
- PMTiles / multipart / tar / zip bundle endpoint — HTTP/2 multistream sufficient (in AZ-505).

## Acceptance Criteria

(7 of the 12 originally-numbered ACs remain in this task; the rest move to AZ-505. AC numbering is preserved so cross-references with the comment thread on AZ-503 stay valid.)

**AC-1: UUIDv5 reference vectors match Python**
Given the test vector `namespace = TILE_NAMESPACE` and `name = "18/12345/23456/google_maps/00000000-0000-0000-0000-000000000000"`
When `Uuidv5.Create(TILE_NAMESPACE, name)` runs
Then the resulting `Guid` is byte-identical to Python `uuid.uuid5(TILE_NAMESPACE, "18/12345/23456/google_maps/00000000-0000-0000-0000-000000000000")` for ≥10 randomly-generated test cases.

**AC-2: Insert is idempotent on identical inputs**
Given a tile is inserted with `(tile_zoom=18, tile_x=A, tile_y=B, tile_size_meters=S, source='google_maps', flight_id=NULL)` returning `id=X`
When the same insert is repeated
Then exactly ONE row exists in `tiles`; the returned `id == X`; the `id` column is not regenerated; `updated_at` IS refreshed but `created_at` is NOT.

**AC-3: Multi-flight UAV uploads coexist**
Given two `source='uav'` inserts for the same `(tile_zoom, tile_x, tile_y, tile_size_meters)` with `flight_id=F1` and `flight_id=F2` (F1 ≠ F2)
When both inserts complete
Then TWO rows exist in `tiles`; each has its own `id`; both rows share the same `location_hash`.

**AC-4: Float rounding does not break idempotence**
Given an insert with `latitude=47.123456789012345` and another insert recomputed from `tile_center = TileToWorldPos(x, y, z)` (slightly different float representation)
When both inserts target the same `(tile_zoom, tile_x, tile_y, tile_size_meters, source, flight_id)`
Then exactly ONE row results; the conflict triggers despite float differences (because the new UPSERT key does not include `latitude`/`longitude`).

**AC-5: Inventory endpoint returns one entry per requested coord** **[→ AZ-505]**

**AC-6: Leaflet path returns most-recent variant via location_hash** **[→ AZ-505]**

**AC-7: content_sha256 is computed and persisted**
Given a UAV upload of a JPEG with known SHA-256
When the insert lands
Then `content_sha256` matches the externally-computed digest; a follow-up insert of a byte-identical body produces the SAME `content_sha256` value.

**AC-8: Migration is reversible (best-effort)**
Given the migration runs forward on a populated `tiles` table
When the back-migration runs
Then the table is restored to the pre-migration shape; data loss is limited to the new columns (`location_hash`, `content_sha256`, `flight_id`, `legacy_id`). (Best-effort because UPSERT key changes are awkward to reverse cleanly.)

**AC-9: Performance — inventory endpoint ≤ 500 ms for 2500 tiles** **[→ AZ-505]**

**AC-10: Leaflet hot path is index-only** **[→ AZ-505]**

**AC-11: Per-flight on-disk separation**
Given two UAV uploads of the same `(z, x, y)` from `flight_id=F1` and `flight_id=F2`
When both inserts complete and the backing JPEGs are persisted
Then two distinct files exist at `./tiles/uav/{F1}/{z}/{x}/{y}.jpg` and `./tiles/uav/{F2}/{z}/{x}/{y}.jpg`; `rm -rf ./tiles/uav/{F1}/` removes ONLY Flight F1's evidence (Flight F2's file is untouched); the DB `file_path` columns reflect the per-flight paths.

**AC-12: HTTP/2 multiplexed responses** **[→ AZ-505]**

## Constraints

- **No column renames**: keep `tile_zoom`, `tile_x`, `tile_y`, `latitude`, `longitude` exactly as named today. The onboard side (`AZ-304`) is responsible for matching column names on its own table.
- **UUID namespace MUST be agreed cross-repo**: pick a fixed UUID (e.g. `urn:uuid:5b8d0c2e-...`) and pin it in BOTH `SatelliteProvider.Common/Utils/Uuidv5.cs` and the onboard `gps_denied_onboard/components/c6_tile_cache/_uuid.py`. Document the chosen value in this task's review.
- **No third-party UUIDv5 dependency**: pure-C# RFC 9562 implementation, ≤80 LoC.
- **Migration must run online**: the `tiles` table is the busiest table in the service. Adding the new columns must be a non-blocking `ALTER TABLE` followed by a backfill in batches.
- **Existing AZ-484 selection rule is preserved**: the read-side `ORDER BY captured_at DESC, updated_at DESC, id DESC` tie-break stays; the only change is the WHERE clause uses `location_hash` instead of `(tile_zoom, tile_x, tile_y)`.

## Risks & Mitigation

**Risk 1: Backfilling `id` on existing rows is irreversible**
- *Risk*: existing rows have random `Guid` ids. If we overwrite them with computed UUIDv5 values, any cached external reference to the old id (e.g. operator UI bookmarks, audit log entries) becomes stale.
- *Mitigation*: add a `legacy_id uuid NULL` column populated from the existing `id` before the backfill. The old id is preserved for diagnostic queries. The new UUIDv5 `id` takes over. After a deprecation window (one cycle), drop `legacy_id`.

**Risk 2: UUIDv5 namespace divergence between Python and C#**
- *Risk*: subtle bug in the C# SHA-1 impl produces ids that differ from Python's. Cross-repo lookups fail silently.
- *Mitigation*: AC-1 requires byte-identical output across ≥10 vectors. The vectors are generated by Python and pasted into the C# test fixture as fixed-string expectations.

**Risk 3: Migration deadlock under load**
- *Risk*: `ALTER TABLE ADD COLUMN ... NOT NULL DEFAULT '...'` on a 10M+ row table locks the table for minutes.
- *Mitigation*: 3-step migration — (a) ADD COLUMN nullable; (b) UPDATE in 1000-row batches with `pg_sleep(0.1)` between batches; (c) SET NOT NULL after backfill. Documented in the migration file.

**Risk 4: Onboard `TileDownloader` (AZ-316) writes against this endpoint before it exists**
- *Risk*: ordering — onboard AZ-316 might be implemented before this task lands. Production calls hit 404.
- *Mitigation*: the onboard `TileDownloader` already has a fallback path (per-tile GET via `/tiles/{z}/{x}/{y}`); document that fallback in AZ-316's caveats and gate the `list-only=true` path behind a feature flag `c11.use_bulk_list_endpoint` (default `false` until this satellite-provider task is in production).

## References

- `gps-denied-onboard/_docs/_process_leftovers/2026-05-12_tile-schema-scenario-analysis.md` — origin + scenario analysis.
- `gps-denied-onboard/_docs/_process_leftovers/2026-05-09_satellite-provider-design-tasks.md` — sibling design tasks (inbound ingest, voting layer).
- `gps-denied-onboard/_docs/02_tasks/todo/AZ-304_c6_postgres_schema.md` — onboard counterpart schema.
- `gps-denied-onboard/_docs/02_tasks/todo/AZ-316_c11_tile_downloader.md` — onboard consumer of the bulk-list endpoint.
- `SatelliteProvider.DataAccess/Repositories/TileRepository.cs` — current `Guid.NewGuid()` + float UPSERT.
- `SatelliteProvider.Services.TileDownloader/TileService.cs` — current `BuildTileEntity`.
- `SatelliteProvider.Api/Program.cs` — endpoint surface.
