# Contract: TileStore 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 collaborator)
- AZ-TBD-c6-cache-budget-eviction (uses `tile_exists` + `delete_tile`)
- TBD at decompose time: E-C2.5 (AZ-256), E-C3 (AZ-257), E-C11 (AZ-251 — both `TileDownloader` and `TileUploader`)
**Version**: 1.0.0
**Status**: draft
**Last Updated**: 2026-05-10

## Purpose

Defines the typed boundary between filesystem-resident tile pixel I/O and every component that produces or consumes JPEG tile bytes. Concrete impls (today only `PostgresFilesystemStore`) write JPEGs to a layout byte-identical to `satellite-provider`'s on-disk format so the C11 `TileUploader` post-landing upload (F10) is a straight copy.

## Shape

### Protocol surface

`typing.Protocol` (PEP 544 structural typing) with `runtime_checkable=True`.

| Method | Signature | Throws / Errors | Blocking? |
|--------|-----------|-----------------|-----------|
| `read_tile_pixels` | `(tile_id: TileId) -> TilePixelHandle` | `TileNotFoundError`, `TileFsError` | sync (mmap, ≤ 0.5 ms warm; ≤ 50 ms cold) |
| `write_tile` | `(tile_blob: bytes, metadata: TileMetadata) -> None` | `TileFsError`, `TileMetadataError`, `ContentHashMismatchError`, `FreshnessRejectionError` | sync (atomic fs write + sidecar) |
| `tile_exists` | `(tile_id: TileId) -> bool` | — | sync (page-cache lookup; ≤ 1 ms) |
| `delete_tile` | `(tile_id: TileId) -> bool` | `TileFsError` | sync (returns `True` if a file was removed; `False` if missing — no-error path for the cache-eviction caller) |

### DTOs

```python
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Optional


@dataclass(frozen=True)
class TileId:
    zoom_level: int          # 0..21 — `satellite-provider` legal range
    lat: float               # WGS84 centre latitude
    lon: float               # WGS84 centre longitude


class TileSource(str, Enum):
    GOOGLEMAPS = "googlemaps"
    ONBOARD_INGEST = "onboard_ingest"


class FreshnessLabel(str, Enum):
    FRESH = "fresh"
    STALE_ACTIVE_CONFLICT = "stale_active_conflict"
    STALE_REAR = "stale_rear"
    DOWNGRADED = "downgraded"


class VotingStatus(str, Enum):
    PENDING = "pending"
    TRUSTED = "trusted"
    REJECTED = "rejected"


@dataclass(frozen=True)
class TileQualityMetadata:
    estimator_label: str               # "satellite_anchored" | "visual_propagated" | "dead_reckoned"
    covariance_2x2: tuple[tuple[float, float], tuple[float, float]]
    last_anchor_age_ms: int
    mre_px: float
    imu_bias_norm: float


@dataclass(frozen=True)
class TileMetadata:
    tile_id: TileId
    tile_size_meters: float
    tile_size_pixels: int
    capture_timestamp: datetime          # ISO 8601 UTC
    source: TileSource
    content_sha256_hex: str              # canonical sha256 of the JPEG body
    freshness_label: FreshnessLabel
    flight_id: Optional[str]             # uuid; set for ONBOARD_INGEST
    companion_id: Optional[str]          # set for ONBOARD_INGEST
    quality_metadata: Optional[TileQualityMetadata]   # set for ONBOARD_INGEST
    voting_status: VotingStatus          # default PENDING for ONBOARD_INGEST


class TilePixelHandle:
    """Opaque handle: filesystem path + mmap pointer. Consumer MUST NOT copy the bytes
    or close the underlying mapping; the handle's lifetime is bounded by the caller's
    use-site `with` block."""

    @property
    def filesystem_path(self) -> Path: ...
    def __enter__(self) -> memoryview: ...
    def __exit__(self, *exc) -> None: ...
```

### Error types

All under `c6_tile_cache.errors`:

```
TileCacheError (Exception subclass)
├── TileNotFoundError          # tile_id not present on disk
├── TileFsError                # I/O error on read/write/rename
├── TileMetadataError          # row missing despite file present, or vice-versa (consistency violation)
├── ContentHashMismatchError   # supplied JPEG bytes don't match declared content_sha256
└── FreshnessRejectionError    # rejected by the C6 freshness gate (raised on insert in active_conflict)
```

`IndexUnavailableError` lives under the same package but is exclusively raised by `DescriptorIndex` — it is not part of `TileStore`'s envelope.

### Filesystem layout

JPEG body lands at `<root>/tiles/{zoom_level}/{x}/{y}.jpg` where `(x, y)` is derived from `(lat, lon, zoom_level)` per the same Web-Mercator tile-coordinate function `satellite-provider` uses (see `satellite-provider/README.md`). A sidecar file `<root>/tiles/{zoom_level}/{x}/{y}.jpg.sha256` carries the canonical content hash (produced by `helpers.sha256_sidecar.atomic_write_with_sidecar` per AZ-280 contract).

## Invariants

- **I-1 (byte-identity with satellite-provider):** for any `(zoom_level, lat, lon)`, the filesystem path computed by C6 `write_tile` MUST equal the path that `satellite-provider` would compute for the same coordinate; any deviation breaks AC-8.4 / F10 upload.
- **I-2 (atomic write + sidecar invariant):** a successful `write_tile` returns only after BOTH the JPEG file AND its `.sha256` sidecar are durable on disk; partial states (file without sidecar or sidecar without file) MUST NOT be observable to readers.
- **I-3 (content-hash gate):** `write_tile` rejects (raises `ContentHashMismatchError`) if `sha256(tile_blob) != metadata.content_sha256_hex`; the cache-poisoning safety budget (D-C10-3 + AC-NEW-7) is bound to this check.
- **I-4 (read mmap is read-only):** `TilePixelHandle.__enter__()` returns a read-only `memoryview`; consumers MUST NOT mutate; a writer that mutates through the mmap is a `Reliability` finding (Critical) at code-review time.
- **I-5 (race-free reads under concurrent F4 writes):** C2 / C2.5 / C3 readers see either the pre-write tile bytes or the post-write tile bytes — never partial bytes. Enforced by `atomicwrites` rename semantics on the writer side.
- **I-6 (idempotent delete):** `delete_tile` returns `False` when the tile is missing; it does NOT raise. The cache-eviction caller relies on this no-error path because it deletes by LRU and may race with a concurrent eviction sweep.
- **I-7 (frozen DTOs):** `TileId`, `TileMetadata`, `TileQualityMetadata` are `@dataclass(frozen=True)`. Mutation raises `FrozenInstanceError`.
- **I-8 (fail-fast on consistency violation):** if a row exists in the metadata store but the JPEG file is missing (or vice-versa), `read_tile_pixels` raises `TileMetadataError` — NOT `TileNotFoundError`. The two errors are the operator's signal that the cache is in a degraded state and needs reprovisioning.

## Non-Goals

- **Not covered: tile descriptor index.** Descriptor mmap + HNSW search is `DescriptorIndex` — separate Protocol, separate contract.
- **Not covered: spatial bbox queries.** `query_by_bbox` is on `TileMetadataStore` — separate Protocol.
- **Not covered: HTTP transport to satellite-provider.** C11 `TileDownloader` / `TileUploader` own transport; they call `TileStore.write_tile` / `TileStore.read_tile_pixels` for the local-side persistence step.
- **Not covered: eviction policy.** `delete_tile` is the eviction primitive; the LRU policy lives in the cache-budget enforcer (separate task).
- **Not covered: multi-process readers writing concurrently.** Single-process producer/consumer per flight; multi-process scenarios are out of scope this cycle.

## Versioning Rules

- **Breaking** (renamed method, removed field, type change, required→optional flip, error class removed from family) requires a major version bump and a coordinated update of every consumer task listed in this header. Producer task MUST surface the change to the user via Choose format before merging.
- **Non-breaking additions** (new optional method via Protocol structural compatibility, new optional field on a DTO with a default, new error variant added to `TileCacheError`) require a minor version bump.
- **Patch** (clarification only, no shape change) is documentation-only.

## Test Cases

| Case | Input | Expected | Notes |
|------|-------|----------|-------|
| protocol-conformance-full | A class implementing all four methods with matching signatures | `isinstance(impl, TileStore) == True` | I-1 / Producer AC-1 |
| protocol-conformance-partial | A class missing `delete_tile` | `isinstance == False` | Drift detection at CI time |
| frozen-dto-mutation | `TileId(zoom_level=18, lat=49.94, lon=36.31).lat = 0.0` | `FrozenInstanceError` | I-7 |
| write-tile-byte-identical | `write_tile(blob, metadata)` for `(zoom=18, lat=49.94, lon=36.31)` | Filesystem path equals `satellite-provider`'s path for same coord; JPEG bytes equal `blob`; sidecar contains `sha256(blob)` | I-1 / I-2 / C6-IT-01 |
| write-tile-content-hash-mismatch | `write_tile(blob, metadata.with(content_sha256_hex="0x00..."))` | `ContentHashMismatchError`; no file written; no sidecar written | I-3 / C6-ST-01 |
| write-tile-freshness-reject | active_conflict sector + stale tile | `FreshnessRejectionError`; no file/row written | Hand-off to freshness-gate task |
| read-tile-pixels-warm | `read_tile_pixels(tile_id)` after a prior write; OS page cache warm | `TilePixelHandle.__enter__()` returns within 0.5 ms; bytes equal the written JPEG body | C6-PT-01 |
| read-tile-pixels-missing | `read_tile_pixels(tile_id)` for never-written tile | `TileNotFoundError` | I-8 (the row-missing-and-file-missing case) |
| read-tile-pixels-row-without-file | metadata row exists; JPEG file deleted out-of-band | `TileMetadataError` (not `TileNotFoundError`) | I-8 |
| concurrent-write-and-read | F4 writer + 9 Hz C2.5 reader on same tile | Reader sees either pre-write or post-write bytes — never partial | I-5 |
| delete-tile-missing | `delete_tile` for a never-written tile | Returns `False`; no exception raised | I-6 |
| delete-tile-existing | `delete_tile` after a prior write | Returns `True`; subsequent `tile_exists` returns `False`; sidecar also removed | I-6 |

## Change Log

| Version | Date | Change | Author |
|---------|------|--------|--------|
| 1.0.0 | 2026-05-10 | Initial contract — Protocol + DTOs + 5-error family + filesystem byte-identity invariant. | autodev (decompose Step 2 of AZ-250 / E-C6) |
