# 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.
