# Contract: TileMetadataStore Protocol

**Component**: c6_tile_cache
**Producer task**: AZ-303 — `_docs/02_tasks/todo/AZ-303_c6_storage_interfaces.md`
**Consumer tasks**:
- AZ-TBD-c6-postgres-filesystem-store (implements)
- AZ-TBD-c6-freshness-gate (insert hook + sector classification reader)
- AZ-TBD-c6-cache-budget-eviction (LRU candidate enumeration + delete coordination)
- TBD at decompose time: E-C10 (AZ-252 — manifest + provisioning), E-C11 (AZ-251 — both `TileDownloader` insert and `TileUploader` reader queries), E-C12 (AZ-253 — operator pre-flight tooling)
**Version**: 1.2.0
**Status**: draft
**Last Updated**: 2026-05-12

## Purpose

Defines the typed boundary to the Postgres-backed spatial index over `TileMetadata`. Concrete impls (today only `PostgresFilesystemStore` — same class also implements `TileStore`) own row insert / bbox query / voting-state transitions. Pre-flight cache builders (C10 / C11 / C12), the F4 mid-flight orthorectifier path (via C5 → C6), and post-landing tooling (C11 `TileUploader`) all consume this surface.

## Shape

### Protocol surface

`typing.Protocol` (PEP 544) with `runtime_checkable=True`. All methods are sync; the Postgres connection pool is owned inside the impl.

| Method | Signature | Throws / Errors | Blocking? |
|--------|-----------|-----------------|-----------|
| `query_by_bbox` | `(bbox: Bbox, zoom: int, *, voting_filter: Optional[VotingStatus] = None, source_filter: Optional[TileSource] = None) -> list[TileMetadata]` | `TileMetadataError` | sync (btree index; ≤ 50 ms typical) |
| `insert_metadata` | `(metadata: TileMetadata) -> None` | `TileMetadataError`, `FreshnessRejectionError` | sync (single-row insert) |
| `update_voting_status` | `(tile_id: TileId, status: VotingStatus) -> None` | `TileMetadataError`, `TileNotFoundError` | sync |
| `mark_uploaded` | `(tile_id: TileId, uploaded_at: datetime) -> None` | `TileMetadataError`, `TileNotFoundError` | sync |
| `pending_uploads` | `() -> list[TileMetadata]` | `TileMetadataError` | sync (filtered query: `source = ONBOARD_INGEST AND uploaded_at IS NULL`) |
| `record_lru_access` | `(tile_id: TileId, accessed_at: datetime) -> None` | `TileMetadataError` | sync (timestamp update only — no row-level read) |
| `lru_candidates` | `(*, max_count: int) -> list[TileMetadata]` | `TileMetadataError` | sync (oldest-`accessed_at`-first; bounded result set) |
| `total_disk_bytes` | `() -> int` | `TileMetadataError` | sync (sum of `disk_bytes` column; ≤ 100 ms even at 100k rows) |
| `get_by_id` | `(tile_id: TileId) -> Optional[TileMetadata]` | `TileMetadataError` | sync; returns `None` if absent (NOT `TileNotFoundError`) |

### DTOs

Reuses `TileId`, `TileMetadata`, `TileQualityMetadata`, `TileSource`, `FreshnessLabel`, `VotingStatus` from `tile_store.md`. The same DTOs are shared across both Protocols by design (single source of truth in `c6_tile_cache._types`).

```python
from dataclasses import dataclass


@dataclass(frozen=True)
class Bbox:
    """Axis-aligned WGS84 bounding box. Inclusive on min, exclusive on max."""
    min_lat: float
    min_lon: float
    max_lat: float
    max_lon: float
```

In addition, `TileMetadata` is extended with two columns owned by the metadata store (NOT meaningful to `TileStore`; see Invariants):

```python
@dataclass(frozen=True)
class TileMetadataPersistent:
    metadata: TileMetadata               # the read-only DTO from tile_store.md
    accessed_at: datetime                # LRU clock — last read time
    uploaded_at: Optional[datetime]      # set when C11 TileUploader has confirmed upload
    disk_bytes: int                      # JPEG body size on disk; tracked for cache-budget enforcement
```

The Protocol returns `TileMetadata` from queries. `TileMetadataPersistent` is the in-process view of LRU and disk-budget state, accessible only via `lru_candidates` / `record_lru_access` / `total_disk_bytes`.

#### TileMetadata.location_hash (v1.2.0)

```python
@dataclass(frozen=True)
class TileMetadata:
    # ...existing AZ-303 v1.1.0 fields unchanged...
    location_hash: UUID | None = None    # uuidv5(TILE_NAMESPACE_UUID, "{zoom}/{tile_x}/{tile_y}")
```

`location_hash` is a deterministic per-cell-bag identifier (UUIDv5, namespace-pinned in `c6_tile_cache._uuid_namespace.TILE_NAMESPACE_UUID`) shared by every row at the same `(zoom_level, tile_x, tile_y)` regardless of source or flight (Scenario 1 UI lookup, Scenario 6 voting query of the 2026-05-12 tile-schema scenario analysis). Defaults to `None` so AZ-303-era constructors continue to work; `PostgresFilesystemStore.insert_metadata` derives the value via `derive_location_hash(zoom_level, tile_x, tile_y)` when `None`, and the DB-side NOT-NULL constraint is the safety net. Cross-repo coordinated with `satellite-provider` per `AZ-TBD_tile_identity_uuidv5_bulk_list`.

### Sector classification (read-only input to the freshness gate)

```python
class SectorClassification(str, Enum):
    ACTIVE_CONFLICT = "active_conflict"
    STABLE_REAR = "stable_rear"


@dataclass(frozen=True)
class SectorBoundary:
    bbox: Bbox
    classification: SectorClassification
```

`SectorClassification` is set pre-flight by the operator via C12; the metadata store reads `SectorBoundary` rows from the sibling table `sector_classifications` (per the AZ-263 baseline schema; AZ-304 adds the NULLable `min_lat` / `min_lon` / `max_lat` / `max_lon` bbox columns operators populate) at insert-time to decide which freshness rule to apply. The Protocol does NOT expose insert-side methods for `SectorBoundary` rows — that surface lives in C12.

## Invariants

- **I-1 (natural-key uniqueness, per-flight separated):** the storage's unique key is `(zoom_level, tile_x, tile_y, tile_size_meters, source, COALESCE(flight_id, '00000000-0000-0000-0000-000000000000'::uuid))`. The integer slippy-tile coordinates `(tile_x, tile_y)` are derived from the DTO's WGS84 `(lat, lon)` and `zoom_level` via the project's shared Web-Mercator helper at insert time; `lat` / `lon` are persisted advisory-only and are NOT part of the uniqueness predicate. The `flight_id` coalesce term means two `ONBOARD_INGEST` rows for the same cell from different flights coexist (required by the future D-PROJ-2 voting layer), while two `GOOGLEMAPS` rows for the same cell (both `flight_id` = NULL → both coalesce to the zero UUID) cannot. Re-inserting an identical natural key with different `content_sha256_hex` raises `TileMetadataError` — no silent overwrite.
- **I-2 (freshness gate at insert):** `insert_metadata` rejects (raises `FreshnessRejectionError`) iff the tile's `(lat, lon)` falls inside an `ACTIVE_CONFLICT` sector AND `capture_timestamp < now() - active_conflict_max_age`. The freshness rules table is configured per-flight (default 6 months for active_conflict; 12 months for stable_rear which downgrades rather than rejects).
- **I-3 (downgrade marking):** when a tile in a `STABLE_REAR` sector is older than `stable_rear_max_age`, the row is inserted with `freshness_label=DOWNGRADED` (NOT rejected). `query_by_bbox` returns the downgrade flag intact so consumers (C2 / C3 spoof-rejection) can act on it.
- **I-4 (LRU clock):** `record_lru_access` updates `accessed_at = max(current accessed_at, supplied timestamp)`; clock skew never sets `accessed_at` backward. `lru_candidates` returns oldest-first.
- **I-5 (disk-budget invariant):** `total_disk_bytes` MUST equal `SUM(disk_bytes)` over all rows where `voting_status != REJECTED`. Rejected rows are tombstones — they keep the on-disk file deleted but retain the row for the manifest's content-hash check (D-C10-3).
- **I-6 (frozen DTOs):** `Bbox`, `SectorBoundary`, `TileMetadataPersistent` are `@dataclass(frozen=True)`.
- **I-7 (transactional writes):** `insert_metadata` is a single transaction over the `tiles` table; the freshness check + the row insert MUST be atomic (a parallel sector-boundary update MUST NOT race the gate).
- **I-8 (no silent voting-status downgrade):** `update_voting_status` accepts only forward transitions (`PENDING → TRUSTED`, `PENDING → REJECTED`); a backward transition raises `TileMetadataError`. `TRUSTED → REJECTED` is allowed (covers the cache-poisoning recall path).
- **I-9 (`pending_uploads` is the single source for C11 TileUploader):** the uploader MUST NOT scan the filesystem for pending tiles; it MUST drive its loop off `pending_uploads()`. The metadata store is the bookkeeping.

## Non-Goals

- **Not covered: filesystem JPEG I/O.** That's `TileStore`.
- **Not covered: descriptor index queries.** That's `DescriptorIndex`.
- **Not covered: sector boundary insert / update.** Owned by C12 operator-tooling against a sibling table; this Protocol is read-only on `SectorBoundary` and does NOT expose CRUD.
- **Not covered: cross-flight aggregation / voting threshold computation.** That's `satellite-provider`'s D-PROJ-2 trust layer (parent suite); C6 just stamps the per-row `voting_status`.
- **Not covered: full-text search / arbitrary-WHERE queries.** Only the methods above; ad-hoc queries go through DBA tooling, not this Protocol.
- **Not covered: schema migrations.** Migration scripts live in `db/migrations/versions/` (project-level Alembic env owned by c6_tile_cache per `module-layout.md`; `0001_initial.py` shipped by AZ-263, `0002_c6_tile_identity_and_lru.py` by AZ-304); the Protocol is shape-only.

## Versioning Rules

Same rules as `tile_store.md` § Versioning Rules.

## Test Cases

| Case | Input | Expected | Notes |
|------|-------|----------|-------|
| protocol-conformance-full | A class implementing all 9 methods | `isinstance(impl, TileMetadataStore) == True` | Producer AC-1 |
| query-by-bbox-basic | bbox covering 100 inserted tiles at zoom=18 | Returns exactly the 100 tiles; `voting_filter=None` returns all statuses | Smoke |
| query-by-bbox-voting-filter | Same with `voting_filter=TRUSTED` | Returns only TRUSTED tiles in bbox | Used by C10 manifest builder |
| insert-duplicate-key | Insert (z=18, tile_x, tile_y, tile_size_meters, src=GOOGLEMAPS, flight_id=NULL) twice with different content_sha256 | First succeeds; second raises `TileMetadataError` | I-1 |
| insert-per-flight-coexists | Insert (z=18, tile_x, tile_y, tile_size_meters, src=ONBOARD_INGEST) twice with different `flight_id` values | Both succeed; rows share the same derived `location_hash` cell-bag identifier | I-1 / D-PROJ-2 |
| insert-active-conflict-stale | Insert into ACTIVE_CONFLICT sector, capture_timestamp = now - 7 months | `FreshnessRejectionError`; row not committed | I-2 / C6-IT-02 |
| insert-stable-rear-stale | Insert into STABLE_REAR sector, capture_timestamp = now - 13 months | Row inserted with `freshness_label=DOWNGRADED` | I-3 |
| update-voting-status-forward | PENDING → TRUSTED | Succeeds | I-8 |
| update-voting-status-backward | TRUSTED → PENDING | `TileMetadataError` | I-8 |
| update-voting-status-trusted-to-rejected | TRUSTED → REJECTED | Succeeds (recall path) | I-8 |
| pending-uploads-empty | No ONBOARD_INGEST tiles | Returns `[]` | I-9 |
| pending-uploads-after-mark | Insert + `mark_uploaded` for half | Returns the unmarked half | I-9 |
| record-lru-access-monotonic | `record_lru_access(t, ts1)` then `record_lru_access(t, ts0 < ts1)` | `accessed_at` stays at `ts1` | I-4 |
| lru-candidates-order | Mixed `accessed_at` for 100 rows; `lru_candidates(max_count=10)` | Returns the 10 oldest in ascending `accessed_at` order | I-4 |
| total-disk-bytes-sum | Insert 5 tiles with known disk_bytes, mark 1 REJECTED | `total_disk_bytes()` excludes the rejected row | I-5 |
| get-by-id-missing | Random tile_id never inserted | Returns `None` (not `TileNotFoundError`) | Documented null-return semantic |
| frozen-dto-mutation | `Bbox(0, 0, 1, 1).min_lat = 5.0` | `FrozenInstanceError` | I-6 |

## Change Log

| Version | Date | Change | Author |
|---------|------|--------|--------|
| 1.0.0 | 2026-05-10 | Initial contract — 9-method Protocol + LRU/disk-budget extensions + freshness gate semantics + composite-key uniqueness invariant. | autodev (decompose Step 2 of AZ-250 / E-C6) |
| 1.1.0 | 2026-05-12 | Non-breaking refinement of Invariant I-1: natural key switched from `(zoom_level, lat, lon, source)` (float-based) to `(zoom_level, tile_x, tile_y, tile_size_meters, source, COALESCE(flight_id, zero_uuid))` (integer + per-flight separated). Protocol surface unchanged; consumers gain the ability to observe multiple ONBOARD_INGEST rows for the same cell from different flights (required by D-PROJ-2 voting). Driven by `_docs/_process_leftovers/2026-05-12_tile-schema-scenario-analysis.md` and the cross-workspace satellite-provider task `AZ-TBD_tile_identity_uuidv5_bulk_list`. | autodev (AZ-304 batch 27 of cycle 1) |
| 1.2.0 | 2026-05-12 | Non-breaking addition of `TileMetadata.location_hash: UUID \| None = None` (cross-source/cross-flight cell-bag identifier; UUIDv5 over `(zoom, tile_x, tile_y)`). Corrected stale references: sector table name (`sector_boundaries` → `sector_classifications`) and Alembic env path (`c6_tile_cache/_alembic/` → `db/migrations/versions/`). Protocol surface unchanged; existing constructors continue to work because the field defaults to `None`. Shipped by AZ-304 alongside the additive `0002_c6_tile_identity_and_lru` migration. | autodev (AZ-304 batch 27 of cycle 1) |
