# C6 Postgres Schema — Additive 0002 Migration (Identity + LRU + Freshness Rules) **Task**: AZ-304_c6_postgres_schema **Name**: C6 Postgres Schema — Identity, LRU, and Freshness Rules (additive 0002) **Description**: Author the additive Alembic migration `0002_c6_tile_identity_and_lru.py` on top of the AZ-263 bootstrap baseline (`db/migrations/versions/0001_initial.py`). This migration is strictly additive per `data_model.md` § 6.1 / § 6.3: every existing AZ-263 column, table, index, and CHECK is preserved unchanged. The migration adds the deterministic-identity columns (`tile_uuid`, `location_hash`), the content-hash chain (`content_sha256`), the LRU + disk-budget bookkeeping columns (`disk_bytes`, `accessed_at`, `uploaded_at`), the per-flight natural-key UNIQUE constraint with COALESCE-zero-uuid semantics, the sector-boundary geometry columns (NULLable additions on the existing `sector_classifications` table), the new `tile_freshness_rules` table seeded with the default thresholds, and the additive widening of the `tiles.freshness_status` CHECK to accept the AZ-303 DTO `FreshnessLabel` vocabulary alongside the existing AZ-263 values. Ships the pinned UUIDv5 namespace module used by both onboard inserts and cross-workspace `satellite-provider` integrations, the migration runner the composition root invokes at startup, the schema-shape diff test, and the AZ-303 contract bump from v1.1.0 → v1.2.0 (adds `location_hash` field to `TileMetadata`). **Complexity**: 3 points **Dependencies**: AZ-303_c6_storage_interfaces (DTO contract — bumped here), AZ-263_initial_structure (baseline 0001_initial migration this builds on), AZ-269_config_loader, AZ-266_log_module **Component**: c6_tile_cache (epic AZ-250 / E-C6) **Tracker**: AZ-304 **Epic**: AZ-250 (E-C6) ### Document Dependencies - `_docs/02_document/contracts/c6_tile_cache/tile_metadata_store.md` — bumped here v1.1.0 → v1.2.0 (adds `location_hash` field). Invariant I-1 already expresses the integer-slippy-tile + COALESCE-zero-uuid natural key this task wires into the DB. - `_docs/02_document/contracts/c6_tile_cache/tile_store.md` — defines `content_sha256_hex` (str) the `tiles.content_sha256` column carries; unchanged this cycle. - `_docs/02_document/contracts/shared_config/composition_root_protocol.md` — `config.tile_cache.postgres_dsn` field. - `_docs/02_document/contracts/shared_logging/log_record_schema.md` — INFO log shape on migration apply / no-op. - `_docs/02_document/data_model.md` — system-wide data model; § 4.4 (migration baseline ordering), § 6.1 (additive-only by default), § 6.3 (`tiles` canonical columns frozen) are the governing rules this task obeys. - `db/migrations/versions/0001_initial.py` — AZ-263 baseline this migration extends additively. - `_docs/_process_leftovers/2026-05-12_tile-schema-scenario-analysis.md` — cross-workspace scenario analysis that established the UUIDv5 + `location_hash` design; the cross-workspace counterpart `AZ-TBD_tile_identity_uuidv5_bulk_list` lives in `satellite-provider/_docs/02_tasks/todo/`. ## Problem The AZ-263 bootstrap baseline shipped the `tiles`, `flights`, `sector_classifications`, `manifests`, and `engine_cache_entries` tables, but the canonical AZ-303 `TileMetadataStore` contract (v1.1.0) requires the schema to also carry: - a deterministic UUIDv5 identifier byte-equal to `satellite-provider`'s tile id, so cross-workspace correlation (D-PROJ-2 ingest, voting, post-landing upload) does not need a separate join table; - a `location_hash` cell-bag identifier for `WHERE location_hash = ?` cell-bag queries (Scenario 1 UI lookup + Scenario 6 voting query in the 2026-05-12 leftover); - the per-flight natural-key UNIQUE constraint (`(zoom_level, tile_x, tile_y, tile_size_meters, source, COALESCE(flight_id, zero_uuid))`) — without it, two `onboard_ingest` rows for the same cell from different flights collapse, destroying the per-flight evidence the future D-PROJ-2 voting layer needs; - the `content_sha256` content-hash column the AZ-280 atomic-write/sidecar pattern chains into; - the LRU + disk-budget bookkeeping columns (`disk_bytes`, `accessed_at`, `uploaded_at`) the `TileMetadataStore.record_lru_access` / `total_disk_bytes` / `pending_uploads` methods read and write; - a freshness-rules table the freshness gate (separate task) reads at insert time; - geometry on `sector_boundaries` records so the freshness gate can perform tile-in-sector containment checks against `SectorBoundary` DTO instances — AZ-263 shipped `sector_classifications` with a flat `sector_id` text column and no bbox geometry. Additionally, the AZ-303 DTO vocabulary for `FreshnessLabel` (`fresh`, `stale_active_conflict`, `stale_rear`, `downgraded`) differs from the AZ-263 baseline `freshness_status` CHECK (`fresh`, `stale_warn`, `stale_reject`). Because `data_model.md` § 6.1 forbids removing CHECK enum values without an ADR, this task widens the CHECK to the UNION of both vocabularies (a CHECK loosening, which IS additive); a future cycle may deprecate the legacy values via ADR-gated cleanup. This task delivers the strictly-additive `0002` migration that closes those gaps without renaming, retyping, or dropping any AZ-263 column, table, index, or CHECK. ## Outcome - An Alembic migration script at `db/migrations/versions/0002_c6_tile_identity_and_lru.py` (forward `upgrade()` is purely additive; reverse `downgrade()` drops the additions and restores the original AZ-263 `freshness_status` CHECK). The migration is idempotent against a DB at AZ-263 head; Alembic rejects double-application via the standard `alembic_version` row. - The migration runner `apply_migrations(config) -> MigrationResult` at `src/gps_denied_onboard/components/c6_tile_cache/migrations.py`, invoked by the composition root at startup AFTER config load and BEFORE `PostgresFilesystemStore` construction. Returns `MigrationResult(applied: list[str], current_revision: str, no_op: bool)`. Logs INFO on every applied revision; logs INFO with `no_op=True` when the DB is already at head. - The pinned UUIDv5 namespace module at `src/gps_denied_onboard/components/c6_tile_cache/_uuid_namespace.py` exporting `TILE_NAMESPACE_UUID = UUID("5b8d0c2e-1a4f-4b3a-8c9d-e7f6a3b2c1d0")`, `derive_tile_id(zoom_level, tile_x, tile_y, source, flight_id) -> UUID`, and `derive_location_hash(zoom_level, tile_x, tile_y) -> UUID`. The namespace value is cross-repo coordinated with `satellite-provider/SatelliteProvider.Common/Utils/Uuidv5.cs` per `AZ-TBD_tile_identity_uuidv5_bulk_list`; the same `uuidv5(NAMESPACE, name)` MUST produce byte-identical output on both sides. - **No `psycopg_pool` helper ships in this task.** The migration runner relies on Alembic's existing SQLAlchemy engine (`engine_from_config(..., poolclass=pool.NullPool)`) already wired in `db/migrations/env.py` by AZ-263. `psycopg_pool` is NOT pinned by AZ-263 (only `psycopg[binary]>=3.1` is), so a runtime connection-pool helper (`PostgresFilesystemStore` use-case) introduces a new dependency and is the responsibility of AZ-305 (`c6_postgres_filesystem_store`). - After `apply_migrations(config)` on an AZ-263-baselined DB: - The `tiles` table has six additional columns (`tile_uuid`, `location_hash`, `content_sha256`, `disk_bytes`, `accessed_at`, `uploaded_at`); the AZ-263 columns are unchanged. - The `tiles` table has a new UNIQUE index `idx_tiles_natural_key` over the COALESCE-zero-uuid natural key; the AZ-263 indices are unchanged. - The `tiles` table has four new indices (`idx_tiles_location_hash`, `idx_tiles_tile_uuid` from the UNIQUE, `idx_tiles_accessed_at`, `idx_tiles_pending_upload` partial, `idx_tiles_flight_captured` partial — see Schema Additions). - The `tiles.freshness_status` CHECK is widened to the UNION vocabulary; AZ-263 rows (none exist on greenfield apply) would continue to validate. - The `sector_classifications` table has four additional NULLable bbox columns; the AZ-263 columns are unchanged. - A new `tile_freshness_rules` table exists, seeded with the two default rows. - The AZ-303 contract `tile_metadata_store.md` is bumped v1.1.0 → v1.2.0 with the `location_hash: UUID | None` field added to the documented `TileMetadata` shape (non-breaking minor — Optional default `None`, populated by `PostgresFilesystemStore.insert_metadata` from `_uuid_namespace.derive_location_hash` when not supplied). - The DTO `TileMetadata` in `src/gps_denied_onboard/components/c6_tile_cache/_types.py` gains the same `location_hash: UUID | None = None` field (positional last, default value preserves existing constructor call sites). - A schema fixture `tests/unit/c6_tile_cache/fixtures/c6_postgres_schema_v2.sql` is the human-readable expected post-0002 DDL used by the schema-shape diff test (kept inside c6_tile_cache's owned test directory). ## Scope ### Included - The Alembic migration `0002_c6_tile_identity_and_lru.py` (additive only; the migration body uses `op.add_column`, `op.create_unique_constraint` / `op.create_index` for the UNIQUE expression index, `op.create_index` for new btree indices, `op.create_table` for `tile_freshness_rules`, `op.bulk_insert` for the seed rows, and a single `op.drop_constraint` + `op.create_check_constraint` pair to widen `ck_tiles_freshness_status`). - A `MigrationResult` dataclass `@dataclass(frozen=True)` at `c6_tile_cache.migrations`. - The `apply_migrations(config) -> MigrationResult` runner using the existing project-pinned Alembic env at `db/migrations/` (no new alembic.ini, no new env.py — AZ-263 bootstrap owns those; this task only wires `target_metadata` into `db/migrations/env.py` so future autogenerate diffs work). - The pinned UUIDv5 namespace module `_uuid_namespace.py` with `TILE_NAMESPACE_UUID`, `derive_tile_id`, `derive_location_hash`. No Postgres dependency; pure stdlib `uuid.uuid5`. - The schema-shape diff test `tests/unit/c6_tile_cache/test_postgres_schema.py` that introspects a freshly-migrated test DB (Alembic upgraded to `0002` head) and asserts every column, index, CHECK constraint, and seed row matches `tests/unit/c6_tile_cache/fixtures/c6_postgres_schema_v2.sql`. Test is `@pytest.mark.docker` (auto-skipped on Tier-1 per `tests/conftest.py`); it consumes the `db` service from `docker-compose.test.yml` via the `DB_URL` env var. No Python logic beyond `information_schema` queries. - The UUIDv5 determinism test `tests/unit/c6_tile_cache/test_uuid_namespace.py` that locks `TILE_NAMESPACE_UUID` and verifies ≥5 fixed `(z, x, y, source, flight_id)` input vectors produce the documented UUIDv5 outputs. These vectors are the cross-repo coordination evidence — the corresponding `satellite-provider` test MUST produce byte-identical UUIDs. - The `db/migrations/env.py` minimum-touch policy: AZ-263 already wires `engine_from_config` + DB_URL fallback for online mode; this task does NOT add `target_metadata` (we use Alembic `op.*` directly, never `autogenerate`). A `target_metadata` wiring lands when the first task adds SQLAlchemy ORM models (none in this task or AZ-305 as currently scoped). - The schema-fixture file `tests/unit/c6_tile_cache/fixtures/c6_postgres_schema_v2.sql` — copy-pastable DDL the test diffs against (lives inside c6_tile_cache's owned test glob per `module-layout.md`). - DTO extension in `_types.py`: `TileMetadata.location_hash: UUID | None = None` (positional last, default `None`). - Contract bump `_docs/02_document/contracts/c6_tile_cache/tile_metadata_store.md` v1.1.0 → v1.2.0 with a Change Log entry. ### Excluded - Concrete `PostgresFilesystemStore` (insert / query / mark methods) — separate task (`c6_postgres_filesystem_store`). That task is responsible for: (a) computing `tile_x` / `tile_y` from the `TileId` WGS84 coordinates via the project's shared Web-Mercator helper, (b) deriving `tile_uuid` / `location_hash` via `_uuid_namespace.derive_*`, (c) mapping DTO field names to AZ-263 column names (`quality_metadata` → `tile_quality_metadata`, `freshness_label` → `freshness_status`, `(lat, lon)` → `(latitude, longitude)`), and (d) validating cross-side UUIDv5 parity with `satellite-provider`. - The freshness gate logic reading `sector_classifications` + `tile_freshness_rules` — separate task (`c6_freshness_gate`). - The LRU eviction policy reading `accessed_at` — separate task (`c6_cache_budget_eviction`). - FAISS index file format — separate task (`c6_faiss_descriptor_index`). - Sector-boundary CRUD (operator-side INSERT/UPDATE of geometry columns) — owned by C12; this task only adds the columns as NULLable. - Per-flight DB lifecycle (drop-and-rebuild between flights, freshness-rules reload) — owned by the composition root's startup orchestration; this task only applies migrations idempotently. - A third migration revision — every future schema change is a NEW migration file; this task only ships `0002`. - Postgres tuning (work_mem, shared_buffers) — handled by deployment / Dockerfile (E-DEPLOY); the schema is portable across reasonable Postgres 16 configurations. - Postgres-version migration (16 → 17) — out of scope this cycle; the schema MUST work on 16.x. - Backfill of the legacy `freshness_status` enum values (`stale_warn`, `stale_reject`) to the new vocabulary — deferred until an ADR explicitly decides the deprecation path; this task widens the CHECK without committing to a value-mapping rewrite. - Renaming any AZ-263 column or table — forbidden per `coderule.mdc` and `data_model.md` § 6.2; the DTO-to-column mapping lives in `PostgresFilesystemStore`. ## Schema Additions (0002 on top of AZ-263 0001_initial) All additions below are strictly additive. The migration MUST NOT drop, rename, or retype any AZ-263 column, table, index, or CHECK; the only constraint change is a CHECK widening (loosening) for `tiles.freshness_status`, which is additive per `data_model.md` § 6.1. ### `tiles` — additive columns | Column | Type | Nullable | Default | Notes | |--------|------|----------|---------|-------| | `tile_uuid` | `UUID` | NO | — | Deterministic `uuidv5(TILE_NAMESPACE_UUID, "{zoom_level}/{tile_x}/{tile_y}/{source}/{flight_id_or_zero_uuid}")`. Cross-repo correlation key with `satellite-provider`. Populated at INSERT by `PostgresFilesystemStore`. UNIQUE. | | `location_hash` | `UUID` | NO | — | `uuidv5(TILE_NAMESPACE_UUID, "{zoom_level}/{tile_x}/{tile_y}")`. Identifies a Web-Mercator cell across sources / flights. Populated at INSERT. | | `content_sha256` | `TEXT` | NO | — | 64 hex chars; matches `tile_store.md` `content_sha256_hex` invariant. CHECK `length(content_sha256) = 64`. | | `disk_bytes` | `BIGINT` | NO | — | On-disk JPEG body size; populated by `write_tile`. CHECK `disk_bytes >= 0`. | | `accessed_at` | `TIMESTAMPTZ` | NO | `now()` | LRU clock; updated by `record_lru_access`. | | `uploaded_at` | `TIMESTAMPTZ` | YES | NULL | Set by `mark_uploaded`; NULL until C11 `TileUploader` confirms post-flight upload. | **Empty-DB assumption**: at 0002 apply time the `tiles` table is empty (greenfield); the four NOT-NULL columns without server defaults are added with `nullable=False` directly. On a hypothetical non-empty DB the migration would need a 2-phase split (add nullable → backfill → alter NOT NULL); this is documented but explicitly out of scope for this cycle. ### `tiles` — natural-key UNIQUE (additive index) A UNIQUE expression index `idx_tiles_natural_key` over ``` (zoom_level, tile_x, tile_y, tile_size_meters, source, COALESCE(flight_id, '00000000-0000-0000-0000-000000000000'::uuid)) ``` Two `onboard_ingest` rows for the same `(z, tile_x, tile_y, tile_size_meters)` from different `flight_id` values coexist (required by the future D-PROJ-2 voting layer). Two `googlemaps` rows for the same cell (both `flight_id = NULL` → both coalesce to the zero UUID) cannot coexist. The index is implemented via `op.create_index(..., unique=True, postgresql_using='btree')` with the expression list literal. ### `tiles` — CHECK widening (additive loosen) The existing `ck_tiles_freshness_status` CHECK is dropped and recreated with the UNION vocabulary: ```sql freshness_status IN ( 'fresh', 'stale_warn', 'stale_reject', -- AZ-263 vocabulary (legacy) 'stale_active_conflict', 'stale_rear', 'downgraded' -- AZ-303 FreshnessLabel vocabulary ) ``` A CHECK widening (more values accepted) is an additive loosening; any row that satisfied the prior CHECK still satisfies the new one. The legacy values are retained until an ADR-gated future cycle deprecates them. The constraint name stays `ck_tiles_freshness_status` to keep the AZ-263 fixture diffable. ### `tiles` — new indices (additive) | Index | Definition | Purpose | |-------|-----------|---------| | `idx_tiles_natural_key` | UNIQUE btree over the COALESCE natural-key expression (above) | I-1 of `tile_metadata_store.md` | | `idx_tiles_location_hash` | btree over `(location_hash)` | Scenario 1 UI cell-bag lookup, Scenario 6 voting query | | `idx_tiles_accessed_at` | btree over `(accessed_at)` | `lru_candidates` ordering | | `idx_tiles_pending_upload` | partial btree over `(uploaded_at)` WHERE `source = 'onboard_ingest' AND uploaded_at IS NULL` | `pending_uploads` | | `idx_tiles_flight_captured` | partial btree over `(flight_id, capture_timestamp)` WHERE `flight_id IS NOT NULL` | Scenario 8 per-flight inspection | The AZ-263 indices (`ix_tiles_zxy`, `ix_tiles_lat_lon`, `ix_tiles_voting_status_onboard`, `ix_tiles_flight_id`, `ix_tiles_created_at`) are unchanged. ### `sector_classifications` — additive columns (geometry, NULLable) | Column | Type | Nullable | Default | Notes | |--------|------|----------|---------|-------| | `min_lat` | `DOUBLE PRECISION` | YES | NULL | bbox south edge — populated pre-flight by C12 | | `min_lon` | `DOUBLE PRECISION` | YES | NULL | bbox west edge | | `max_lat` | `DOUBLE PRECISION` | YES | NULL | bbox north edge | | `max_lon` | `DOUBLE PRECISION` | YES | NULL | bbox east edge | All NULLable because existing AZ-263 rows (if any) lack geometry; C12 populates pre-flight before the freshness gate reads. The AZ-263 columns (`sector_id`, `classification`, `freshness_threshold_days`) are unchanged. No CHECK on min ≤ max yet — that arrives when C12 enforces population (would be a tightening, ADR-gated). The `SectorBoundary` DTO from `_types.py` maps `bbox.min_lat → min_lat`, etc.; `classification` maps directly. The DTO field `sector_id` is not present in the DTO today because the in-memory `SectorBoundary` does not need it; the impl task resolves DB row ↔ DTO via the `sector_id` text PK component. ### New table: `tile_freshness_rules` | Column | Type | Nullable | Default | Notes | |--------|------|----------|---------|-------| | `classification` | `TEXT` | NO | — | PK; matches `sector_classifications.classification` | | `max_age_seconds` | `BIGINT` | NO | — | Per-classification threshold | | `action` | `TEXT` | NO | — | CHECK `action IN ('reject','downgrade')` | | `set_at` | `TIMESTAMPTZ` | NO | `now()` | | Constraints: - `PRIMARY KEY (classification)` - `CHECK (action IN ('reject', 'downgrade'))` - `CHECK (max_age_seconds > 0)` Default rows seeded by the migration (AC-7): - `('active_conflict', 15552000, 'reject')` — 6 × 30 × 86400 seconds = 6 months - `('stable_rear', 31104000, 'downgrade')` — 12 × 30 × 86400 seconds = 12 months ### AZ-303 contract bump v1.1.0 → v1.2.0: `TileMetadata.location_hash` - DTO change in `_types.py`: add `location_hash: UUID | None = None` to `TileMetadata` (positional last, default `None`). `PostgresFilesystemStore.insert_metadata` computes the value via `_uuid_namespace.derive_location_hash(...)` when `None`; uses the supplied value when present. - Contract change in `tile_metadata_store.md`: bump version to v1.2.0; add `location_hash: UUID | None` row to the DTO field table; add a "v1.2.0" Change Log row referencing this task and the leftover. - Non-breaking: existing constructors and `insinstance(impl, TileMetadataStore)` checks continue to work. ## Acceptance Criteria **AC-1: Migration is idempotent against an AZ-263-baselined DB** Given a Postgres 16 database at AZ-263 head (`alembic_version = '0001_initial'`) When `apply_migrations(config)` runs Then the additive columns / indices / table / CHECK widening exist; the `alembic_version` row carries `0002_c6_tile_identity_and_lru`; `MigrationResult.applied == ['0002_c6_tile_identity_and_lru']`; `MigrationResult.no_op == False`; AZ-263 columns / indices / CHECKs are byte-identical to their pre-migration state. **AC-2: Migration is no-op when at head** Given a Postgres DB already at `0002_c6_tile_identity_and_lru` When `apply_migrations(config)` runs again Then `MigrationResult.applied == []`; `MigrationResult.no_op == True`; no DDL is emitted (verifiable via `pg_stat_user_tables` row counts unchanged). **AC-3: Schema shape matches the documented DDL** Given a DB upgraded through 0001 + 0002 When the schema-shape diff test introspects `information_schema.columns` / `pg_indexes` / `pg_constraint` / `tile_freshness_rules` row contents Then every AZ-263 column / index / CHECK is present and unchanged; every additive column / index / CHECK from this task is present; the `ck_tiles_freshness_status` CHECK contains the UNION vocabulary; `tile_freshness_rules` has exactly two seeded rows with the documented values. Diff against `tests/unit/c6_tile_cache/fixtures/c6_postgres_schema_v2.sql` is empty. **AC-4: Natural-key uniqueness enforces per-flight separation** Given a `tiles` table after 0002 When two INSERTs with the same `(zoom_level, tile_x, tile_y, tile_size_meters, source='onboard_ingest')` and **different** `flight_id` values are attempted Then both INSERTs succeed; the resulting rows have different `tile_uuid` values (different UUIDv5 inputs) and the same `location_hash` (UUIDv5 inputs differ only in `flight_id`/`source`). **AC-4b: Natural-key uniqueness rejects duplicate flight inserts** Given a `tiles` table after 0002 When two INSERTs with the same `(zoom_level, tile_x, tile_y, tile_size_meters, source, flight_id)` (or both `flight_id = NULL` for `googlemaps`) are attempted with different `content_sha256` values Then the second INSERT raises `psycopg.errors.UniqueViolation` (`idx_tiles_natural_key`); the first row is unaffected. **AC-5: AZ-263 CHECK constraints survive widening** Given the post-0002 `tiles` table When `INSERT INTO tiles (...freshness_status...) VALUES ('fresh')` runs (and separately for `'stale_warn'`, `'stale_reject'`, `'stale_active_conflict'`, `'stale_rear'`, `'downgraded'`) Then all six values are accepted; an `INSERT ... VALUES ('bogus')` is rejected by the widened `ck_tiles_freshness_status` CHECK. **AC-6: Down migration reverses cleanly** Given a DB at `0002_c6_tile_identity_and_lru` When `alembic downgrade -1` runs (operator-only command; not exercised by the runtime) Then the additive columns / indices / `tile_freshness_rules` table are dropped; the `ck_tiles_freshness_status` CHECK is restored to the AZ-263 vocabulary; the DB returns to byte-identical AZ-263 state; subsequent `upgrade` re-applies cleanly. **AC-7: Default freshness rules are seeded** Given a DB upgraded through 0002 When the schema-shape test queries `tile_freshness_rules` Then exactly two rows exist: `('active_conflict', 15552000, 'reject')` and `('stable_rear', 31104000, 'downgrade')`. **AC-8: Migration runner logs INFO on apply and no-op** Given a DB at AZ-263 head When `apply_migrations` runs and then runs again Then the first call emits an INFO log with `kind="c6.migration.applied"` carrying `revisions=['0002_c6_tile_identity_and_lru']`; the second call emits an INFO log with `kind="c6.migration.no_op"`. Both calls also log the resolved `TILE_NAMESPACE_UUID` value once for post-mortem drift detection. **AC-9: AZ-263 columns are byte-identical after upgrade** Given a DB at AZ-263 head + a recorded snapshot of `information_schema.columns` for `tiles`, `flights`, `sector_classifications`, `manifests`, `engine_cache_entries` When `apply_migrations(config)` runs Then for every AZ-263 column the snapshot and the post-0002 row are byte-identical (column name, data type, nullability, default expression). New columns appear only as additions. **AC-10: UUIDv5 derivation is deterministic and cross-repo coordinated** Given the pinned `TILE_NAMESPACE_UUID = 5b8d0c2e-1a4f-4b3a-8c9d-e7f6a3b2c1d0` When `derive_tile_id` and `derive_location_hash` are called for the documented fixed test vectors (≥5 `(zoom_level, tile_x, tile_y, source, flight_id)` tuples in `tests/unit/c6_tile_cache/test_uuid_namespace.py`) Then each call returns the documented UUID byte-for-byte; the same Python expression run twice produces identical output; the locked test vectors are the cross-repo coordination evidence used to verify `satellite-provider`'s C# implementation produces the same UUIDs. **AC-11: `location_hash` is invariant across `source` and `flight_id`** Given three rows for the same `(zoom_level, tile_x, tile_y)` — one `source='googlemaps'` and two `source='onboard_ingest'` from different `flight_id` values When the test queries `SELECT DISTINCT location_hash FROM tiles WHERE zoom_level=? AND tile_x=? AND tile_y=?` Then exactly ONE `location_hash` value is returned (the three rows share the same cell-bag identifier). **AC-12: `TileMetadata.location_hash` default is `None` (non-breaking DTO bump)** Given the v1.2.0 `TileMetadata` DTO When existing AZ-303-style positional constructors run (without supplying `location_hash`) Then construction succeeds with `location_hash = None`; when constructors supply an explicit `UUID`, the value is preserved on the frozen instance. ## Non-Functional Requirements **Performance** - Migration apply ≤ 5 s on an AZ-263-baselined Postgres 16 database with empty `tiles`. The migration body is bounded by index creation on an empty table, four `ALTER TABLE … ADD COLUMN`s, one `op.create_table`, two `op.bulk_insert` rows, and one CHECK widening — all O(1) in row count. - `apply_migrations` no-op call (DB at head) ≤ 100 ms. - The new UNIQUE expression index `idx_tiles_natural_key` does not regress AZ-263 query plans on the existing `ix_tiles_zxy` btree; both coexist and the planner selects based on predicate shape. **Compatibility** - Postgres 16.x. - `psycopg[binary]>=3.1` and `alembic>=1.13` — both pinned by AZ-263. This task adds **no** new third-party dependencies. `psycopg_pool` is explicitly deferred to AZ-305 since AZ-263 did not pin it. - Cross-workspace UUIDv5 namespace: `TILE_NAMESPACE_UUID` MUST be byte-identical to the satellite-provider C# constant; any change requires a coordinated cross-repo release. **Reliability** - The migration is wrapped in a single Alembic transaction (Postgres default). A crash mid-migration leaves the DB at the prior revision (`0001_initial`). - The runner catches `psycopg.errors.SerializationFailure` and retries once with exponential backoff; after the second failure, raises `MigrationError` (defined in `c6_tile_cache.migrations`, NOT in `TileCacheError` — migrations run before any runtime error consumer is constructed). ## Unit Tests | AC Ref | What to Test | Required Outcome | |--------|-------------|-----------------| | AC-1 | `apply_migrations` against the `db` Postgres service (docker-compose.test.yml) previously upgraded to AZ-263 head | All additive columns / indices / `tile_freshness_rules` table exist; `alembic_version='0002_c6_tile_identity_and_lru'`; `result.applied=['0002_c6_tile_identity_and_lru']`. AZ-263 columns / indices / CHECKs byte-identical to pre-migration snapshot. | | AC-2 | `apply_migrations` against DB already at 0002 head | `result.applied=[]`; `result.no_op=True`; no DDL emitted. | | AC-3 | Introspect `information_schema` / `pg_indexes` / `pg_constraint` / `tile_freshness_rules` rows; diff against `tests/unit/c6_tile_cache/fixtures/c6_postgres_schema_v2.sql` | Zero diff. | | AC-4 | Two `onboard_ingest` INSERTs with same `(z, tile_x, tile_y, tile_size_meters)` and different `flight_id` | Both succeed; same `location_hash`; different `tile_uuid`. | | AC-4b | Two INSERTs with identical natural-key tuple (same `flight_id` or both NULL for `googlemaps`) | Second INSERT raises `psycopg.errors.UniqueViolation`. | | AC-5 | INSERT one row each for `'fresh'`, `'stale_warn'`, `'stale_reject'`, `'stale_active_conflict'`, `'stale_rear'`, `'downgraded'`; INSERT one row with `'bogus'` | First six succeed; last raises `psycopg.errors.CheckViolation`. | | AC-6 | `alembic downgrade -1` then `upgrade head` | DB returns to AZ-263-byte-identical state; subsequent upgrade re-applies cleanly. | | AC-7 | SELECT `tile_freshness_rules` after migration | Exactly 2 rows with documented values. | | AC-8 | Capture log records during migration apply + no-op | INFO records with `kind="c6.migration.applied"` and `kind="c6.migration.no_op"`; namespace UUID emitted on apply. | | AC-9 | Snapshot AZ-263 `information_schema.columns` before 0002; compare after | Snapshot rows byte-identical post-migration; new column rows are additions only. | | AC-10 | `derive_tile_id` / `derive_location_hash` for 5 fixed input vectors | Outputs match the documented UUIDs byte-for-byte; idempotent on second call. | | AC-11 | INSERT three rows sharing `(z, tile_x, tile_y)` from different `(source, flight_id)` | Exactly one distinct `location_hash` value. | | AC-12 | Construct `TileMetadata` without `location_hash` and with explicit `location_hash=uuid4()` | First yields `location_hash=None`; second preserves the supplied value; both instances are frozen. | | NFR-perf-apply | Migration apply on AZ-263-baselined empty `tiles` | Wall ≤ 5 s | | NFR-perf-noop | `apply_migrations` no-op timing | Wall ≤ 100 ms | | NFR-reliability-retry | Inject `SerializationFailure` once, then succeed | Migration succeeds on retry; on second failure raises `MigrationError`. | ## Constraints - Postgres 16.x ONLY this cycle; no SQLite / no MySQL fallback. - Alembic + `psycopg[binary]` are pinned by AZ-263; this task does NOT introduce new third-party dependencies. `psycopg_pool` is NOT introduced here (deferred to AZ-305). - The migration MUST be reversible (`downgrade` drops the additions cleanly and restores the AZ-263 CHECK) — operator post-flight tooling depends on it for "drop-and-rebuild" flows. - The migration MUST be strictly additive on every AZ-263 column, table, index, and CHECK per `data_model.md` § 6.1 / § 6.3 and `coderule.mdc`. The single allowed constraint mutation is the `ck_tiles_freshness_status` CHECK widening, which is additive in semantic effect. - `pgcrypto` extension is NOT required by 0002 (no `gen_random_uuid()` use); `tile_freshness_rules.classification` is a TEXT PK and the seeded rows are static. - `MigrationError` is NOT a member of the `TileCacheError` family — migrations run before any `c6_tile_cache.errors` consumer is constructed. - The schema-fixture file `tests/unit/c6_tile_cache/fixtures/c6_postgres_schema_v2.sql` is the diff target; updating it without a new migration revision is a Spec-Gap finding (High) at code-review time. - The pinned `TILE_NAMESPACE_UUID` MUST NOT be regenerated by this task. The value `5b8d0c2e-1a4f-4b3a-8c9d-e7f6a3b2c1d0` is locked here; subsequent edits require a coordinated cross-workspace release. - The `latitude` / `longitude` columns (AZ-263 names) remain advisory; `lat` / `lon` are NOT introduced. The DTO `TileId(zoom_level, lat, lon)` maps via `PostgresFilesystemStore` serialisation; the schema is NOT changed to match the DTO field name. - The `freshness_status` column name (AZ-263) is NOT renamed to `freshness_label`. DTO field `freshness_label: FreshnessLabel` maps via `PostgresFilesystemStore` serialisation. - The `tile_quality_metadata` column name (AZ-263) is NOT renamed to `quality_metadata`. DTO field `quality_metadata: TileQualityMetadata | None` maps via `PostgresFilesystemStore` serialisation. - The `tiles.id BIGSERIAL` PK is NOT replaced. The deterministic UUIDv5 lives in the additive `tile_uuid` column; cross-workspace correlation uses `tile_uuid` (not `id`). Satellite-provider may use UUIDv5 as its own PK; the wire-format correlation key is the UUIDv5 value, not the column name. - The migration assumes empty `tiles` at apply time (greenfield). NOT-NULL adds on existing rows would require a 2-phase migration (add nullable + backfill + alter NOT NULL); explicitly out of scope. - AZ-263 legacy `freshness_status` values (`stale_warn`, `stale_reject`) are NOT deprecated by this task. A future ADR-gated migration may tighten the CHECK once any rows carrying those values are backfilled. ## Risks & Mitigation **Risk 1: AZ-263 baseline drift between dev and CI breaks the additive assumption** - *Risk*: A developer manually edits `0001_initial.py` after AZ-263 merged; 0002 fails to apply because expected base state diverges. - *Mitigation*: AC-9 snapshots AZ-263 column metadata before 0002 and asserts byte-identity post-migration. AC-3 diffs full schema against `c6_postgres_schema_v2.sql`. CI catches drift before merge. **Risk 2: Empty-DB assumption violated in dev fixtures** - *Risk*: A dev or test fixture inserts `tiles` rows between `0001_initial` and `0002_c6_tile_identity_and_lru`; the NOT-NULL additive columns fail. - *Mitigation*: Document the empty-DB assumption in Constraints and in the migration's docstring. The composition root applies migrations BEFORE any seed step. Test fixtures use `apply_migrations` first, then INSERT. **Risk 3: Down-migration data loss is irreversible if rows exist** - *Risk*: Operator runs `alembic downgrade -1` on a DB with live tiles; the `tile_uuid` / `location_hash` / `content_sha256` columns are dropped, destroying cross-workspace correlation evidence. - *Mitigation*: Downgrade is documented operator-only and destructive. The composition root only calls `upgrade head` at runtime. Document in the migration docstring. **Risk 4: UNIQUE expression index on COALESCE is slow or unsupported** - *Risk*: The COALESCE-zero-uuid expression index is unusual; Postgres 16 supports it but plan-tooling may not recognise it for query optimisation. - *Mitigation*: AC-4 / AC-4b exercise the index directly. The expression is deterministic and Postgres documents COALESCE-expression indices as supported since v8.0; no version-specific risk on 16.x. **Risk 5: CHECK widening creates ambiguity in downstream consumers** - *Risk*: Code reading `freshness_status` may not handle the new 4-value subset and crash on legacy `stale_warn`/`stale_reject` rows (or vice-versa). - *Mitigation*: No legacy rows exist on greenfield. The DTO `FreshnessLabel` enum is the canonical onboard vocabulary; `PostgresFilesystemStore` is responsible for the value-mapping policy. Document the legacy values as deprecated-pending-ADR in the contract bump. **Risk 6: UUIDv5 namespace divergence between onboard Python and satellite-provider C#** - *Risk*: A subtle bug in the cross-repo C# UUIDv5 implementation produces UUIDs that differ from Python's `uuid.uuid5`. Cross-repo lookups fail silently. - *Mitigation*: AC-10 locks 5+ fixed vectors with documented expected output. The corresponding `satellite-provider` test (per `AZ-TBD_tile_identity_uuidv5_bulk_list` AC-1) asserts the same vectors produce byte-identical output. **Risk 7: AZ-303 DTO `location_hash` default `None` shadowing impl bugs** - *Risk*: `PostgresFilesystemStore.insert_metadata` forgets to derive `location_hash` when the DTO field is `None`; INSERT fails the NOT-NULL DB constraint at runtime. - *Mitigation*: The impl task's AC includes a "derive-on-None" unit test. The DB-side NOT-NULL is the safety net — fail-fast, not silent. Documented in the contract bump. **Risk 8: Future cross-workspace renegotiation of the namespace UUID** - *Risk*: A future cycle wants to rotate `TILE_NAMESPACE_UUID`; doing so without coordination invalidates every existing `tile_uuid` and `location_hash`. - *Mitigation*: Constraint forbids regeneration in this task; future change requires an ADR + coordinated cross-workspace release. The migration runner logs the namespace value used at apply time. ## Runtime Completeness - **Named capability**: Postgres 16 deterministic-identity columns + per-flight natural-key UNIQUE + LRU/upload bookkeeping + sector-boundary geometry + per-classification freshness rules — all additive on the AZ-263 baseline (description.md / data_model.md / AC-NEW-3 / AC-NEW-6 / RESTRICT-SAT-2 / 2026-05-12 leftover § 4–5). - **Production code that must exist**: real Alembic migration `0002_c6_tile_identity_and_lru.py`, real `apply_migrations` runner driving Alembic's `command.upgrade(cfg, "head")` against the AZ-263 env, real schema-fixture diff test, real `_uuid_namespace` module with `TILE_NAMESPACE_UUID` constant and `derive_tile_id` / `derive_location_hash` helpers, real DTO extension in `_types.py`, real contract bump in `tile_metadata_store.md`. **NOT included** in this task: a `psycopg_pool` connection helper (deferred to AZ-305). - **Allowed external stubs**: tests requiring a real Postgres are marked `@pytest.mark.docker` and run against the `db` service from `docker-compose.test.yml` (per the existing AZ-263 test-infra convention — see `tests/conftest.py` auto-skip logic). The schema-shape test reads `DB_URL` from the env at run time; on Tier-1 (`GPS_DENIED_TIER != 2`) the test auto-skips. Production wiring uses the operator's deployed Postgres. - **Unacceptable substitutes**: SQLite "for testing only" — production and test environments MUST both be Postgres 16; raw SQL DDL applied without Alembic (would defeat the version-tracking the runner depends on); a `tile_quality_metadata` validation at the DB layer (would lock the schema to the JSONB shape — the application-side validation is the single source of truth); a non-deterministic `tile_uuid` strategy (would defeat the cross-workspace coordination the namespace pin establishes); any operation that renames, retypes, or drops an AZ-263 column / table / index / CHECK (forbidden per `coderule.mdc` and `data_model.md` § 6.2 / § 6.3); a parallel Alembic env at `src/.../c6_tile_cache/_alembic/` (forbidden — the project uses one alembic env at `db/migrations/` per AZ-263 + `alembic.ini`). ## Contract This task does NOT produce a new contract file — it implements the `tile_metadata_store.md` contract's persistence surface and bumps its version v1.1.0 → v1.2.0 with one non-breaking minor addition (`TileMetadata.location_hash: UUID | None = None`). The schema-fixture file `tests/unit/c6_tile_cache/fixtures/c6_postgres_schema_v2.sql` is the diff target referenced in `tile_metadata_store.md` § Test Cases (`schema-shape-fixture-diff`) — but the contract document of record stays the Protocol contract.