[AZ-503] Tile identity → UUIDv5 + integer UPSERT (foundation)
ci/woodpecker/push/01-test Pipeline was successful
ci/woodpecker/push/02-build-push Pipeline was successful

Foundation half of original AZ-503 (split during /autodev step 10 batch 2
on user choice; deferred work moved to AZ-505 with a Blocks link).

Adds deterministic tile identity (UUIDv5 over (z, x, y, source, flight_id))
shared cross-repo with gps-denied-onboard via the pinned TileNamespace
5b8d0c2e-7f1a-4d3b-9c5e-1f3a8e7d2b6c, switches the tiles UPSERT key from
floats to integers with per-flight separation, plumbs FlightId through
UavTileMetadata + handler, and writes UAV evidence to per-flight
on-disk directories so two flights at the same (z, x, y) coexist.

- Common: pure-C# RFC 9562 Uuidv5 (no third-party dep) + FlightId DTO
  field; 10 Python-reference unit vectors verify byte parity.
- DataAccess: migration 014 adds flight_id (uuid NULL), location_hash
  (uuid NOT NULL, backfilled via session-scoped pg_temp.uuidv5),
  content_sha256 (bytea NULL), legacy_id (uuid NULL = preserves
  pre-AZ-503 random id one cycle); drops idx_tiles_unique_location_source
  (AZ-484) and adds idx_tiles_unique_identity keyed on
  (tile_zoom, tile_x, tile_y, tile_size_meters, source,
   COALESCE(flight_id, '00000000-...'::uuid)) + idx_tiles_location_hash.
- TileRepository: ColumnList + UPSERT updated; id never updated on
  conflict (preserves AC-2 idempotence). UpdateAsync extended.
- Services: TileService and UavTileUploadHandler compute deterministic
  Id + LocationHash + ContentSha256 before insert; UAV file path
  becomes ./tiles/uav/{flight_id or 'none'}/{z}/{x}/{y}.jpg.
- Tests: Uuidv5Tests (10 reference vectors), UavTileFilePathTests
  (per-flight + anonymous paths), UavTileUploadHandlerTests (AC-2,
  AC-3, AC-7, AC-11 unit-level), UavUploadTests (AC-3 + AC-4
  integration: multi-flight DB coexistence with shared location_hash
  + distinct file_path; float-different lat/lon collapse to 1 row),
  MigrationTests (column shape, idx_tiles_unique_identity supersedes
  AZ-484 index, deterministic backfill).
- IntegrationTests project references Common to reuse Uuidv5 in raw
  SQL seeds.
- AZ-488 MultiSourceCoexistence seed fixed to populate location_hash
  (otherwise migration 014's NOT NULL constraint fails).

ACs covered: AC-1, AC-2, AC-3, AC-4, AC-7, AC-8, AC-11.
ACs deferred to AZ-505: AC-5, AC-6, AC-9, AC-10, AC-12.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-12 17:07:35 +03:00
parent f6197499a4
commit c646aa93e2
17 changed files with 1154 additions and 117 deletions
+9 -5
View File
@@ -96,10 +96,13 @@ Source: cycle-3 perf-harness leftover replay surfaced the host SDK / project SDK
Source: cross-workspace handoff from `gps-denied-onboard` (tile-schema scenario analysis) for AZ-503; cycle-3 perf-harness leftover replay-obligation closure for AZ-504. Both attach to epic AZ-483 (Multi-source tile storage + UAV upload, Layer 2) — AZ-503 supersedes the AZ-484 UPSERT-conflict-key portion, AZ-504 unblocks PT-08 measurement.
**Cycle 5 split (during /autodev Step 10 batch 2)**: AZ-503 was specced as 3 SP but reconciled at ~5 SP once the codebase was inspected (`flight_id` / `voting_status` columns + `UavTileMetadata.FlightId` field didn't exist). User picked Option C: split AZ-503 into AZ-503-foundation (this cycle) + AZ-505 (next cycle). AZ-505 is `Blocks`-linked to AZ-503 and waits for the columns to land.
| Task | Title | Depends On | Points | Status |
|------|-------|-----------|--------|--------|
| AZ-503 | Tile identity → UUIDv5 + integer UPSERT + bulk-list endpoint | AZ-484 (supersedes UPSERT-conflict-key portion of AZ-484 selection rule) | 3 | To Do |
| AZ-504 | Perf script: fix grep \| wc -l pipefail crash in PT-08 | — (independent; references AZ-488 PT-08 threshold) | 1 | To Do |
| AZ-503 | Tile identity → UUIDv5 + integer UPSERT (foundation half — split from original AZ-503) | AZ-484 (supersedes UPSERT-conflict-key portion of AZ-484 selection rule) | 3 | Done (In Testing, batch 2 cycle 5) |
| AZ-504 | Perf script: fix grep \| wc -l pipefail crash in PT-08 | — (independent; references AZ-488 PT-08 threshold) | 1 | Done (In Testing, batch 1 cycle 5) |
| AZ-505 | Tile inventory endpoint + HTTP/2 + leaflet covering index | AZ-503 (HARD, Blocks-linked — needs `location_hash` + `flight_id` columns) | 3 | To Do (cycle 6 candidate) |
## Execution Order
@@ -146,10 +149,11 @@ Single task; coordinated cross-cutting bump.
### Step 9 cycle 5
Independent tracks — both can run in parallel; no ordering constraint between them. AZ-504 is a prerequisite for the cycle's Step 15 Performance Test to deliver a green PT-08 reading (and therefore for deleting the perf-cycle3 leftover); AZ-503 is the cycle's main feature.
Independent tracks — both can run in parallel; no ordering constraint between them. AZ-504 is a prerequisite for the cycle's Step 15 Performance Test to deliver a green PT-08 reading (and therefore for deleting the perf-cycle3 leftover); AZ-503 is the cycle's main feature (foundation half — see split note above).
1. AZ-504 (1 SP) — cheapest unblocker; lands first to clear PT-08 reporting for the cycle.
2. AZ-503 (3 SP) — main feature; data-model + API; cross-workspace alignment with `gps-denied-onboard` AZ-304 / AZ-316.
2. AZ-503 (3 SP, foundation half) — main feature; data-model + identity plumbing; cross-workspace alignment with `gps-denied-onboard` AZ-304.
3. AZ-505 (3 SP) — deferred to next cycle; `Blocks`-linked to AZ-503.
## Total Effort
@@ -160,7 +164,7 @@ Step 9 cycle 1: 1 task created (AZ-484, 5 pts)
Step 9 cycle 2: 2 tasks created (AZ-487 = 2 pts, AZ-488 = 8 pts over-cap user-accepted) — total 10 pts
Step 9 cycle 3: 6 tasks created (AZ-491 = 3 pts, AZ-492 = 3 pts, AZ-493 = 2 pts, AZ-494 = 2 pts, AZ-495 = 1 pt, AZ-496 = 2 pts) — total 13 pts
Step 9 cycle 4: 1 task created (AZ-500 = 5 pts)
Step 9 cycle 5: 2 tasks created (AZ-503 = 3 pts, AZ-504 = 1 pt) — total 4 pts
Step 9 cycle 5: 3 tasks tracked (AZ-503 = 3 pts foundation-half, AZ-504 = 1 pt, AZ-505 = 3 pts split-off-deferred) — 4 pts committed to cycle 5, 3 pts deferred to cycle 6
## Coverage Verification
@@ -1,14 +1,25 @@
# Tile identity → UUIDv5 + integer UPSERT + bulk-list endpoint
# Tile identity → UUIDv5 + integer UPSERT (foundation)
**Task**: AZ-503_tile_identity_uuidv5_bulk_list
**Name**: Tile identity → UUIDv5 + integer UPSERT + bulk-list endpoint
**Description**: Tile identity in the `tiles` table is currently random (`Guid.NewGuid()`), and the UPSERT conflict key uses `double precision` `latitude`/`longitude` and omits `flight_id`, which (a) makes idempotent re-insert fragile against float rounding and (b) destroys per-flight evidence required by the D-PROJ-2 multi-flight voting layer when two UAVs upload the same `(z, x, y)` cell. This task migrates tile identity to deterministic UUIDv5 (`id = uuidv5(NAMESPACE, "{z}/{x}/{y}/{source}/{flight_id or 'none'}")`), adds a `location_hash` UUIDv5 (`uuidv5(..., "{z}/{x}/{y}")`) for efficient cell-bag queries (UI Leaflet path + future voting), switches the UPSERT conflict key to integer-only `(zoom_level, tile_x, tile_y, tile_size_meters, source, COALESCE(flight_id, '00000000-0000-0000-0000-000000000000'::uuid))`, adds a `content_sha256 bytea NOT NULL` column for content-addressable dedup, and adds the `POST /api/satellite/tiles/inventory` endpoint that the onboard `TileDownloader` (`gps-denied-onboard` AZ-316) needs for bbox→tile enumeration during pre-flight provisioning.
**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)
**Component**: SatelliteProvider.DataAccess + SatelliteProvider.Services.TileDownloader + SatelliteProvider.Api
**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.
@@ -84,31 +95,51 @@ Three concrete issues in the current code:
## Scope
### Included
- `SatelliteProvider.Common/Utils/Uuidv5.cs` — pure-C# RFC 9562 UUIDv5 implementation, unit-tested against the Python `uuid.uuid5` reference vectors (the onboard side uses Python `uuid.uuid5`; both must produce byte-identical output for the same name + namespace).
- `SatelliteProvider.DataAccess` — Dapper SQL changes: new columns, new UPSERT, new SELECT shapes. `TileRepository.GetByLocationHashAsync` and `TileRepository.InventoryAsync(uuid[])` added; `GetByTileCoordinatesAsync` rewritten to use `location_hash`. Existing `tiles_leaflet_path` covering index added.
- `SatelliteProvider.Services.TileDownloader``BuildTileEntity` no longer calls `Guid.NewGuid()`; it computes the UUIDv5 and the `location_hash` from the deterministic inputs. Same change in `UavTileUploadHandler`.
- `SatelliteProvider.Api/Program.cs` — new MapPost route `/api/satellite/tiles/inventory`; existing `/tiles/{z}/{x}/{y}` Leaflet path migrated to use `location_hash`-keyed query against the covering index.
- Migration script in the existing migrations tool (whichever the repo uses — Flyway/EFCore/handwritten SQL; this task uses whatever is already established).
- **On-disk layout migration**: UAV tiles move from `./tiles/uav/{zoom}/{x}/{y}.jpg` to `./tiles/uav/{flight_id}/{zoom}/{x}/{y}.jpg`. Google Maps tiles stay at `./tiles/{zoom}/{x}/{y}/...jpg` (or normalise to `./tiles/google_maps/{zoom}/{x}/{y}.jpg` if the cleanup is cheap). The DB `file_path` column is rewritten in the same backfill that populates `location_hash`/`content_sha256`. Test `SatelliteProvider.Tests/UavTileFilePathTests.cs:23` is updated to assert the new path shape.
- OpenAPI annotations for the new endpoint.
- Unit tests for `Uuidv5` against Python reference vectors.
- Integration tests for the new POST `/api/satellite/tiles/inventory` surface (use existing `docker-compose.tests.yml` fixture).
### 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`).
- **Enable HTTP/2 (and HTTP/3 over TLS where feasible)** at the Kestrel endpoint boundary: `EndpointDefaults.Protocols = HttpProtocols.Http1AndHttp2AndHttp3`. Verify the dev `docker-compose` nginx reverse proxy also has `http2 on;` in the relevant `listen` directive. This is the bulk-retrieval mechanism for BOTH Leaflet (browser opens one TCP connection, multiplexes 30+ tile streams, HPACK compresses repeated headers) and UAV provisioning (`httpx.Client(http2=True)` on the onboard side). No application-level batching is added.
- **No materialised `tile_current` pointer table** — deferred until production profiling demands it. Pre-optimisation rejected.
- **No content-addressable / blob storage layout**`content_sha256` is for dedup *detection* (and integrity), not dedup *storage*. CAS adds complexity without measurable benefit at our scale.
- **No multipart / tar / zip bundle endpoint** for UAV provisioning — rejected in favour of inventory POST + per-tile GET over HTTP/2 multiplex. The bundle approach collapses resume granularity, loses per-tile cacheability, and gives no throughput win over HTTP/2 multistream. PMTiles archive is excellent for STATIC tile sets (Cloudflare/Protomaps) but our DB is dynamic — UAV uploads invalidate any pre-built archive. Defer PMTiles until profiling demands it.
- Unit tests for `Uuidv5` against Python reference vectors (≥10 cases).
### Excluded
- The voting / trust-promotion layer (Design Task #2 from 2026-05-09 leftover) — separate task. This task makes voting POSSIBLE by keeping per-flight rows; it does NOT implement voting.
- Onboard companion auth (mTLS / signed payloads) — already covered by D-PROJ-2 Design Task #1.
- Renaming the `tile_zoom` column to `zoom_level` (rule: never rename columns without explicit confirmation — see `coderule.mdc`).
- Per-flight key management (already covered by gps-denied-onboard AZ-318).
- Removing the existing `latitude`/`longitude` columns. They stay as advisory center-of-tile data.
### 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
@@ -129,15 +160,9 @@ Given an insert with `latitude=47.123456789012345` and another insert recomputed
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**
Given a POST body of 25 `(z, x, y)` coords at zoom 18, with 12 already in the DB and 13 absent
When `POST /api/satellite/tiles/inventory` is called
Then `results` contains 25 entries in the SAME ORDER as the input; 12 entries have `present=true` with `id`/`location_hash`/`captured_at` populated, 13 entries have `present=false` with `location_hash` populated (computed via UUIDv5) and `id=null`; per-tile `estimated_bytes` is `null|int`.
**AC-5: Inventory endpoint returns one entry per requested coord** **[→ AZ-505]**
**AC-6: Leaflet path returns most-recent variant via location_hash**
Given multiple rows for `(z, x, y)` from different sources/flights
When `GET /tiles/{z}/{x}/{y}` is called
Then ONE tile body is returned, selected by `WHERE location_hash = $1 ORDER BY captured_at DESC, updated_at DESC, id DESC LIMIT 1` (semantically identical to AZ-484's prior rule, now using `location_hash`).
**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
@@ -147,28 +172,19 @@ Then `content_sha256` matches the externally-computed digest; a follow-up insert
**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`). (Best-effort because UPSERT key changes are awkward to reverse cleanly.)
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**
Given a POST body listing 2500 `(z, x, y)` coords at zoom 18 against a populated DB (average ~3 versions per cell across `google_maps` + `uav` sources)
When `POST /api/satellite/tiles/inventory` is called
Then the response arrives within 500 ms (95th percentile over 20 calls). Index-only scan via `tiles_leaflet_path` is the expected plan.
**AC-9: Performance — inventory endpoint ≤ 500 ms for 2500 tiles** **[→ AZ-505]**
**AC-10: Leaflet hot path is index-only**
Given the `tiles_leaflet_path` covering index exists and the table has ≥ 100k rows
When `EXPLAIN (ANALYZE, BUFFERS) SELECT file_path FROM tiles WHERE location_hash = $1 AND voting_status IN ('trusted', NULL) ORDER BY captured_at DESC LIMIT 1` is run
Then the plan is `Index Only Scan using tiles_leaflet_path`; `Heap Fetches = 0` (visibility map fully built); total time < 0.5 ms.
**AC-12: HTTP/2 multiplexed responses**
Given Kestrel is configured with `Http1AndHttp2AndHttp3` (or `Http1AndHttp2` over plain TLS without QUIC support)
When a single `httpx.Client(http2=True)` issues 20 concurrent `GET /tiles/{z}/{x}/{y}` requests
Then the responses arrive over ONE TCP connection (verifiable via packet capture / `httpx.Response.http_version == 'HTTP/2'`); all 20 responses interleave on the wire; total wall-clock time < 2× single-tile latency (vs. 20× for HTTP/1.1 without pipelining); per-tile ETags + `Cache-Control` headers are preserved unchanged.
**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.