[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
@@ -0,0 +1,104 @@
-- AZ-503-foundation: deterministic tile identity (UUIDv5) + multi-flight evidence preservation.
--
-- Adds four columns to `tiles`:
-- - flight_id (uuid NULL) — per-UAV-flight identifier. NULL for google_maps and
-- legacy UAV rows; populated for AZ-503+ UAV uploads.
-- - location_hash (uuid NOT NULL) — UUIDv5(TILE_NAMESPACE, "{tile_zoom}/{tile_x}/{tile_y}").
-- Drives leaflet hot-path lookups and future voting layer.
-- - content_sha256 (bytea NULL) — SHA-256 of the JPEG body at insert time. NULL for legacy
-- rows (pre-AZ-503), NOT NULL for new rows enforced at the
-- application layer (TileEntity / repositories). Kept NULL-able
-- at the column level because the migration cannot read tile
-- files from disk safely (path may have moved, file may be
-- gone). Application invariant: SHA-256 only meaningful when
-- not NULL.
-- - legacy_id (uuid NULL) — preserves the pre-AZ-503 random id of each row for one
-- deprecation cycle (per AZ-503 Risk 1). Dropped in a
-- follow-up migration once external references to legacy
-- ids are confirmed flushed.
--
-- Switches the UPSERT conflict key from (latitude, longitude, tile_zoom, tile_size_meters, source)
-- to an integer-only key with per-flight separation:
-- (tile_zoom, tile_x, tile_y, tile_size_meters, source, COALESCE(flight_id, '00000000-...'::uuid))
-- so two UAV flights uploading the same (z, x, y) cell coexist as distinct rows.
--
-- TILE_NAMESPACE is pinned cross-repo at 5b8d0c2e-7f1a-4d3b-9c5e-1f3a8e7d2b6c (matches
-- SatelliteProvider.Common.Utils.Uuidv5.TileNamespace and gps-denied-onboard
-- c6_tile_cache/_uuid.py). DO NOT change without updating both sides.
--
-- Whole migration runs inside one transaction; partial failure leaves the table without the
-- new columns rather than half-migrated (per AZ-484 precedent for tile-table migrations).
BEGIN;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Helper: pure-SQL UUIDv5 (SHA-1-based, RFC 9562 §5.5). Used ONLY for the
-- location_hash backfill below. Application writes compute the same UUIDv5
-- via SatelliteProvider.Common.Utils.Uuidv5.Create (verified byte-identical
-- against Python uuid.uuid5 in AZ-503 AC-1).
CREATE OR REPLACE FUNCTION pg_temp.uuidv5(namespace_uuid uuid, name text) RETURNS uuid AS $$
DECLARE
ns_bytes bytea;
hash bytea;
b6 int;
b8 int;
BEGIN
-- Namespace UUID as 16 big-endian bytes.
ns_bytes := decode(replace(namespace_uuid::text, '-', ''), 'hex');
hash := substring(digest(ns_bytes || convert_to(name, 'UTF8'), 'sha1') from 1 for 16);
-- Set version = 5 (upper nibble of byte 6).
b6 := (get_byte(hash, 6) & 15) | 80;
hash := set_byte(hash, 6, b6);
-- Set RFC 4122 variant (upper 2 bits of byte 8 = 10).
b8 := (get_byte(hash, 8) & 63) | 128;
hash := set_byte(hash, 8, b8);
RETURN encode(hash, 'hex')::uuid;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
ALTER TABLE tiles ADD COLUMN IF NOT EXISTS flight_id uuid;
ALTER TABLE tiles ADD COLUMN IF NOT EXISTS location_hash uuid;
ALTER TABLE tiles ADD COLUMN IF NOT EXISTS content_sha256 bytea;
ALTER TABLE tiles ADD COLUMN IF NOT EXISTS legacy_id uuid;
-- Preserve the pre-AZ-503 random id under legacy_id for the deprecation window.
UPDATE tiles
SET legacy_id = id
WHERE legacy_id IS NULL;
-- Backfill location_hash for every existing row. Deterministic; same algorithm
-- the application uses for new writes.
UPDATE tiles
SET location_hash = pg_temp.uuidv5(
'5b8d0c2e-7f1a-4d3b-9c5e-1f3a8e7d2b6c'::uuid,
tile_zoom::text || '/' || tile_x::text || '/' || tile_y::text)
WHERE location_hash IS NULL;
-- location_hash is now populated for every row; promote to NOT NULL.
ALTER TABLE tiles ALTER COLUMN location_hash SET NOT NULL;
-- content_sha256 is intentionally left nullable for legacy rows (the migration cannot
-- safely re-read tile files: paths may have rotated, files may be absent). The application
-- layer enforces NOT NULL for all writes starting at AZ-503; legacy NULLs are treated as
-- "unverified content" and surfaced as such if/when integrity checks are added downstream.
-- Drop AZ-484's lat/lon-keyed unique index and replace with the integer + flight_id key.
DROP INDEX IF EXISTS idx_tiles_unique_location_source;
DROP INDEX IF EXISTS idx_tiles_unique_location;
CREATE UNIQUE INDEX IF NOT EXISTS idx_tiles_unique_identity
ON tiles (
tile_zoom,
tile_x,
tile_y,
tile_size_meters,
source,
COALESCE(flight_id, '00000000-0000-0000-0000-000000000000'::uuid)
);
-- Lookup index on location_hash for application reads (kept lightweight here;
-- the larger covering index `tiles_leaflet_path` is owned by AZ-505).
CREATE INDEX IF NOT EXISTS idx_tiles_location_hash ON tiles (location_hash);
COMMIT;