# C6 PostgresFilesystemStore — TileStore + TileMetadataStore Production Impl

**Task**: AZ-305_c6_postgres_filesystem_store
**Name**: C6 PostgresFilesystemStore
**Description**: Implement `PostgresFilesystemStore`, the single production class that satisfies BOTH the `TileStore` Protocol (filesystem-backed JPEG I/O byte-identical to `satellite-provider`) AND the `TileMetadataStore` Protocol (Postgres-backed spatial / LRU / voting state). Owns the full insert path (atomic-write + sha256 sidecar via AZ-280, content-hash gate, single-transaction row insert), the read path (mmap-backed `TilePixelHandle`, btree-indexed bbox query, LRU access stamp), and the bookkeeping path (`mark_uploaded`, `update_voting_status`, `lru_candidates`, `total_disk_bytes`). The freshness gate's `FreshnessRejectionError` raise point is wired here but the rule-evaluation logic lives in the freshness-gate task; the LRU eviction policy lives in the cache-budget-eviction task — this store exposes the primitives both consume.
**Complexity**: 5 points
**Dependencies**: AZ-303_c6_storage_interfaces, AZ-304_c6_postgres_schema, AZ-280_sha256_sidecar, AZ-279_wgs_converter, AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module, AZ-273_fdr_client_ringbuf
**Component**: c6_tile_cache (epic AZ-250 / E-C6)
**Tracker**: AZ-305
**Epic**: AZ-250 (E-C6)

### Document Dependencies

- `_docs/02_document/contracts/c6_tile_cache/tile_store.md` — Protocol this task implements; produced by AZ-303.
- `_docs/02_document/contracts/c6_tile_cache/tile_metadata_store.md` — Protocol this task implements; produced by AZ-303.
- `_docs/02_document/contracts/shared_helpers/sha256_sidecar.md` — atomic-write + sidecar pattern used by `write_tile`; produced by AZ-280.
- `_docs/02_document/contracts/shared_helpers/wgs_converter.md` — `(lat, lon, zoom_level)` → `(x, y)` Web-Mercator tile-coordinate conversion the byte-identity invariant depends on; produced by AZ-279.
- `_docs/02_document/contracts/shared_fdr_client/fdr_record_schema.md` — `kind="c6.write"`, `kind="c6.write_failed"`, `kind="c6.evicted"` records the store emits.
- `_docs/02_document/contracts/shared_logging/log_record_schema.md` — INFO/WARN/ERROR log shapes.

## Problem

Without a real `PostgresFilesystemStore`:

- The C2.5 / C3 read path has no production impl — `read_tile_pixels` cannot return any bytes; F3 hot path stalls before C2.5.
- The F4 mid-flight tile-gen write path has nothing to land tiles in — `write_tile` is a hole; AC-8.4 fails.
- The C11 `TileDownloader` has no insert target — F1 pre-flight provisioning cannot persist Google Maps tiles; takeoff blocks.
- The C11 `TileUploader` has no `pending_uploads()` source — F10 post-landing upload is silent.
- The C10 manifest builder has no `query_by_bbox` — F1 cannot enumerate trusted tiles.
- The byte-identity invariant from `tile_store.md` § I-1 is unenforced — F10 upload would be a re-encode rather than a copy, breaking the upload contract with `satellite-provider`.

This task is the production-default impl. The Protocols, contracts, schema, and helpers are now ready; this is the integration point.

## Outcome

- A `PostgresFilesystemStore` class at `src/gps_denied_onboard/components/c6_tile_cache/postgres_filesystem_store.py` that conforms to BOTH the `TileStore` and `TileMetadataStore` Protocols (verified by AZ-303's `runtime_checkable` test).
- Constructor signature: `__init__(self, *, root_dir: Path, postgres_pool: psycopg_pool.ConnectionPool, sha256_sidecar: Sha256SidecarHelper, wgs_converter: WgsConverter, fdr_client: FdrClient, logger: Logger)`. The composition root wires the dependencies; this class owns no globals.
- `read_tile_pixels(tile_id) -> TilePixelHandle`: computes the path via the injected `wgs_converter`, opens the JPEG via `mmap.mmap(fileno, 0, prot=mmap.PROT_READ)`, returns a `MmapTilePixelHandle` (subclass of the `TilePixelHandle` ABC from AZ-303). Raises `TileNotFoundError` when both row and file are absent; `TileMetadataError` when row exists but file is missing (or vice-versa); `TileFsError` on syscall failure.
- `write_tile(tile_blob, metadata) -> None`:
  1. Validates `sha256(tile_blob) == metadata.content_sha256_hex`; mismatch → `ContentHashMismatchError` (no I/O performed).
  2. Computes the canonical path via `wgs_converter`.
  3. (Freshness gate hook — implemented by the freshness-gate task; this task wires the call site so the gate raises `FreshnessRejectionError` BEFORE filesystem write; if the gate is OFF in config, the check is skipped.)
  4. Writes the JPEG + `.sha256` sidecar via the injected `sha256_sidecar.atomic_write_with_sidecar(path, tile_blob)`.
  5. Inserts the metadata row in a single Postgres transaction. On unique-violation → roll back, delete the just-written file/sidecar (compensating action), raise `TileMetadataError`.
  6. Emits an FDR record `kind="c6.write"` with `tile_id`, `source`, `disk_bytes`, `content_sha256`.
- `tile_exists(tile_id) -> bool`: SELECT 1 from `tiles` for the composite key; returns True iff a row exists. Does NOT touch the filesystem (the row is the source of truth; consistency is checked on `read_tile_pixels`).
- `delete_tile(tile_id) -> bool`: deletes the row, the JPEG file, and the sidecar — in that order — inside a single transaction with the filesystem ops. Returns `False` if the row was already absent (no-error path per `tile_store.md` § I-6).
- `query_by_bbox(bbox, zoom, *, voting_filter, source_filter) -> list[TileMetadata]`: parameterised SELECT against the composite btree; respects the optional filters. Returns rows ordered by `(lat, lon)` for deterministic test output.
- `insert_metadata(metadata) -> None`: SELECT-side equivalent of `write_tile`'s row insert (used by the rebuild path that has the file already on disk — e.g., F1 pre-flight when `TileDownloader` has placed JPEGs and only the row needs to land). Validates the file exists at the canonical path and that its sha256 matches `metadata.content_sha256_hex`; raises `ContentHashMismatchError` on mismatch, `TileFsError` on absent file. Does NOT re-execute the freshness gate (callers that bypass `write_tile` must run the gate themselves).
- `update_voting_status(tile_id, status) -> None`: UPDATE with the I-8 forward-only transition validation in app-layer (PENDING→TRUSTED, PENDING→REJECTED, TRUSTED→REJECTED allowed; backwards raises `TileMetadataError`).
- `mark_uploaded(tile_id, uploaded_at) -> None`: UPDATE `uploaded_at`; raises `TileNotFoundError` if the row is absent.
- `pending_uploads() -> list[TileMetadata]`: SELECT against the partial index `idx_tiles_pending_upload`.
- `record_lru_access(tile_id, accessed_at) -> None`: UPDATE `accessed_at = GREATEST(accessed_at, $1)` (monotonic per `tile_metadata_store.md` § I-4).
- `lru_candidates(*, max_count) -> list[TileMetadata]`: SELECT ORDER BY `accessed_at ASC` LIMIT `max_count`.
- `total_disk_bytes() -> int`: SELECT COALESCE(SUM(disk_bytes), 0) FROM tiles WHERE voting_status != 'rejected'.
- `get_by_id(tile_id) -> Optional[TileMetadata]`: returns `None` on absence (NOT `TileNotFoundError`).
- All third-party exceptions (psycopg errors, OS errors) are caught and rewrapped into the `TileCacheError` family.
- ERROR log on every `TileMetadataError` / `ContentHashMismatchError`; WARN log on every `write_tile` retry; INFO log on store construction with row count + disk bytes; DEBUG log on every read/write (off by default per `tile_store.md` perf table).

## Scope

### Included

- `PostgresFilesystemStore` class implementation conforming to both Protocols.
- `MmapTilePixelHandle` subclass of the `TilePixelHandle` ABC.
- The compensating-delete on insert failure: if the filesystem write succeeded but the row insert failed, the file + sidecar are deleted before the exception propagates; this preserves I-2 (atomic write + sidecar invariant means atomic file+row pair from the consumer's perspective).
- Per-call query parameter binding via psycopg's parameterised query API (no string interpolation; SQL injection is not a vector but the parameterised path is also faster).
- Connection pool sizing per `config.tile_cache.postgres_pool_size` (default 4 — bounded so a runaway query loop cannot DoS the pool).
- Freshness-gate call site (a method `_evaluate_freshness(metadata) -> Optional[FreshnessLabel]` that the freshness-gate task substitutes; this task ships the trivial pass-through `return metadata.freshness_label` so existing tests pass; the gate task replaces the body).
- Transaction boundaries: every multi-statement operation is in a single transaction; SAVEPOINT only for the `read_tile_pixels` consistency check (read-side does not need its own transaction since the schema enforces the one-row invariant).
- Filesystem path computation via the injected `wgs_converter` ONLY — NEVER hardcode the Web-Mercator math here; the byte-identity invariant tracks `wgs_converter`'s output directly.
- Idempotent constructor: a re-constructed store against an existing DB + filesystem reads the existing state; does NOT truncate or rebuild.
- A standalone CLI `python -m c6_tile_cache.tools dump <tile_id>` for operator post-flight inspection (no formal contract — just calls `read_tile_pixels` + writes JPEG to stdout).
- Connection-failure handling: if the pool is unreachable on construction, raises `TileMetadataError` (NOT a separate `ConnectionError` — keeps the family flat).

### Excluded

- The freshness gate's rule-evaluation logic — separate task (`c6_freshness_gate`); this task ships only the pass-through hook.
- The 10 GB LRU eviction loop — separate task (`c6_cache_budget_eviction`); this task exposes `lru_candidates` / `delete_tile` / `total_disk_bytes` as the primitives the eviction policy consumes.
- The FAISS descriptor index — separate task (`c6_faiss_descriptor_index`); this task does NOT implement the `DescriptorIndex` Protocol.
- The orthorectifier (used by F4 to project nav-camera frames into tile-space JPEG bytes) — owned by C5; this task receives the bytes via `write_tile`.
- The C11 `TileDownloader` / `TileUploader` HTTP clients — separate epic.
- C10's manifest builder — separate epic.
- Postgres tuning, server config, replica setup — handled by E-DEPLOY.
- Multi-process producer/consumer — single-process per flight per `tile_store.md` Non-Goals.
- Tile orthorectification math — NOT here.

## Acceptance Criteria

**AC-1: Round-trip write + read is byte-identical**
Given a JPEG body `B` of N bytes with `content_sha256_hex = sha256(B)`, and `metadata` with a known `(zoom_level, lat, lon)`
When `write_tile(B, metadata)` returns and a subsequent `read_tile_pixels(metadata.tile_id)` is called
Then `read_tile_pixels(...).__enter__()` exposes a `memoryview` whose bytes equal `B`; the filesystem path equals the path `wgs_converter` computes for the same coordinate; the `.sha256` sidecar file's content equals `content_sha256_hex` followed by a newline (per AZ-280 contract)

**AC-2: Content-hash mismatch is rejected before any I/O**
Given `metadata.content_sha256_hex` deliberately set to a wrong value
When `write_tile(B, metadata)` is called
Then `ContentHashMismatchError` is raised; no JPEG file is written; no sidecar is written; no Postgres row is inserted; an ERROR log records the rejection with `tile_id` and the expected vs actual hashes

**AC-3: Composite-key duplicate raises TileMetadataError + compensating delete**
Given a `tiles` row already exists for `(zoom=18, lat, lon, source='googlemaps')`
When a second `write_tile` is attempted with the same key but different `content_sha256_hex` and different `B`
Then `TileMetadataError` is raised; the second JPEG file is NOT left on disk (compensating delete ran); the original row + file are unchanged; an ERROR log records the duplicate

**AC-4: Row-without-file consistency fault is fail-fast**
Given a `tiles` row for `(zoom, lat, lon, source)` whose JPEG file has been deleted out-of-band
When `read_tile_pixels(tile_id)` is called
Then `TileMetadataError` (NOT `TileNotFoundError`) is raised with a message identifying both the row and the missing path; the operator's signal that the cache is in a degraded state

**AC-5: query_by_bbox returns deterministic results**
Given 100 inserted rows uniformly distributed across a 1°×1° bbox at zoom=18
When `query_by_bbox(bbox=that_1deg, zoom=18, voting_filter=None, source_filter=None)` is called
Then exactly 100 rows are returned, ordered by `(lat ASC, lon ASC)`; the EXPLAIN plan uses `idx_tiles_spatial` (verifiable via `EXPLAIN (BUFFERS, FORMAT JSON)` parsing in the test)

**AC-6: query_by_bbox honours filters**
Given the same 100 rows with mixed `voting_status` (50 PENDING, 50 TRUSTED)
When `query_by_bbox(..., voting_filter=VotingStatus.TRUSTED)` is called
Then exactly the 50 TRUSTED rows are returned; PENDING rows are excluded; an analogous test holds for `source_filter`

**AC-7: update_voting_status enforces the forward-transitions table**
Given a row with `voting_status = TRUSTED`
When `update_voting_status(tile_id, VotingStatus.PENDING)` is called
Then `TileMetadataError` is raised with a message naming the disallowed transition; the row is unchanged
And: TRUSTED → REJECTED is allowed (covers cache-poisoning recall); PENDING → TRUSTED and PENDING → REJECTED are allowed

**AC-8: mark_uploaded sets uploaded_at and pending_uploads excludes it**
Given an `onboard_ingest` row with `uploaded_at = NULL`
When `mark_uploaded(tile_id, datetime.utcnow())` is called and then `pending_uploads()` is called
Then `pending_uploads()` does NOT contain that tile_id; the row's `uploaded_at` matches the supplied timestamp within 1 ms

**AC-9: record_lru_access is monotonic**
Given a row with `accessed_at = T1`
When `record_lru_access(tile_id, T0 < T1)` is called
Then the row's `accessed_at` is unchanged (`T1`); a subsequent `record_lru_access(tile_id, T2 > T1)` updates to `T2`

**AC-10: total_disk_bytes excludes rejected rows**
Given 5 rows with `disk_bytes = 100, 200, 300, 400, 500` and `voting_status = (TRUSTED, TRUSTED, TRUSTED, TRUSTED, REJECTED)`
When `total_disk_bytes()` is called
Then the result is `1000` (the rejected row's 500 bytes are excluded per I-5)

**AC-11: delete_tile is idempotent and removes filesystem artefacts**
Given a row + JPEG + sidecar at canonical path
When `delete_tile(tile_id)` is called once and then again
Then the first call returns `True` and removes row + file + sidecar; the second call returns `False` (no exception); subsequent `tile_exists` returns `False`

**AC-12: third-party exceptions are rewrapped**
Given a Postgres pool that is intentionally killed mid-call (testcontainer stop)
When any Protocol method is called
Then a `TileMetadataError` is raised (NOT a raw `psycopg.OperationalError`); the original error message is preserved in the rewrapped exception's `__cause__`

**AC-13: read_tile_pixels p95 budget**
Given a warmed-up store (page cache hot for the queried tile)
When `read_tile_pixels` is called 1000 times
Then `__enter__()` returns within 0.5 ms p95 (failure threshold 5 ms); cold first read is within 50 ms (failure threshold 200 ms) — matches C6-PT-01

**AC-14: write_tile sustains 5 Hz peak F4 burst without dropping**
Given an idle store and `wgs_converter`-mocked path
When 100 `write_tile` calls are issued at 5 Hz from a single producer
Then all 100 land within 30 s; the metadata-store's `total_disk_bytes` reports the sum of all 100 `disk_bytes`; no INSERT failed (matches C6-IT-04 / AC-NEW-3)

**AC-15: FDR record on every write**
Given a successful `write_tile`
When the FDR record is captured
Then a single `kind="c6.write"` record is emitted with `producer_id="c6_tile_cache.store"`, payload `{tile_id, source, disk_bytes, content_sha256}` matching `fdr_record_schema.md`; on `write_tile` failure, `kind="c6.write_failed"` is emitted with the failure reason

## Non-Functional Requirements

**Performance**
- `read_tile_pixels` p95 ≤ 0.5 ms warm; ≤ 50 ms cold (AC-13 / C6-PT-01).
- `write_tile` sustains 5 Hz burst (AC-14 / AC-NEW-3).
- `query_by_bbox` ≤ 50 ms typical for a single sector at zoom=18 (≤ a few hundred matched rows).
- `total_disk_bytes` ≤ 100 ms even at 100k rows (single SUM).
- Pool size default 4; pool checkout p99 ≤ 5 ms under nominal load.

**Compatibility**
- Postgres 16.x.
- `psycopg_pool` 3.x and `psycopg` 3.x — already pinned.
- `mmap` from stdlib — no Python C-extension shenanigans.

**Reliability**
- All errors rewrap third-party exceptions into `TileCacheError` family.
- Insert is transactional with a compensating filesystem delete on row-side failure (preserves the file+row pair invariant).
- The store NEVER blocks the F3 hot path on its own internal locks — `read_tile_pixels` acquires no locks beyond the OS page cache.
- The pool is bounded; a connection leak is detected at process exit with a WARN log and the leak count.

## Unit Tests

| AC Ref | What to Test | Required Outcome |
|--------|-------------|-----------------|
| AC-1 | Round-trip write + read of a known JPEG | mmap bytes equal source; sidecar content matches; path matches wgs_converter |
| AC-2 | write_tile with bad content_sha256_hex | ContentHashMismatchError; no fs/db effects; ERROR log |
| AC-3 | Duplicate composite-key write | TileMetadataError; compensating delete leaves original intact |
| AC-4 | Row exists; file deleted out-of-band; read | TileMetadataError (not TileNotFoundError) |
| AC-5 | 100 rows in 1° bbox; query | 100 results in lat,lon ASC order; EXPLAIN uses idx_tiles_spatial |
| AC-6 | query with voting_filter / source_filter | Only matching rows returned |
| AC-7 | TRUSTED → PENDING attempt | TileMetadataError; TRUSTED → REJECTED allowed |
| AC-8 | mark_uploaded then pending_uploads | Tile excluded; uploaded_at matches |
| AC-9 | record_lru_access with backwards timestamp | accessed_at unchanged; forward-only |
| AC-10 | total_disk_bytes with 1 REJECTED among 5 rows | Sum excludes REJECTED |
| AC-11 | delete_tile twice | First returns True, removes artefacts; second returns False |
| AC-12 | Pool killed mid-call | TileMetadataError; original psycopg error in __cause__ |
| AC-13 | Microbench read_tile_pixels × 1000 warm | p95 ≤ 0.5 ms |
| AC-14 | 100 writes at 5 Hz | All land within 30 s; no drops |
| AC-15 | FDR capture on write success/failure | Record kind matches; payload fields match schema |
| NFR-perf-pool-checkout | Microbench pool checkout × 1000 | p99 ≤ 5 ms |
| NFR-reliability-leak-detection | Force-leak a connection then exit | WARN log on exit with leak count |

## Constraints

- The class implements BOTH Protocols on a single instance — splitting them across two classes would force the composition root to wire two near-identical objects against the same `(root_dir, postgres_pool)`. The single-instance pattern is documented in `tile_metadata_store.md` (the impl note that `PostgresFilesystemStore` also implements `TileStore`).
- Filesystem path computation MUST go through the injected `wgs_converter` — NEVER duplicate the Web-Mercator math here. Direct math is a `Architecture` finding (High) at code-review time.
- `mmap` opens the file `prot=mmap.PROT_READ`; tests assert the resulting `memoryview` is `readonly=True`.
- Postgres parameterised queries via psycopg's `cursor.execute(sql, params)` ONLY; no string-interpolated SQL.
- Compensating delete on insert failure is mandatory — leaving an orphan JPEG would skew `total_disk_bytes` and silently violate the cache budget.
- The class is NOT thread-safe for `write_tile` — concurrent writes to the same tile_id from two threads is undefined behaviour. Single-writer-per-tile is the F4 path's contract; any future multi-writer scenario is a separate task.
- The store does NOT log per-frame DEBUG by default — `read_tile_pixels` is in the F3 hot path and DEBUG would flood at 9 Hz aggregate.
- This task introduces no new third-party dependencies — `psycopg`, `psycopg_pool`, `mmap` (stdlib), and the AZ-280 / AZ-279 helpers are sufficient.

## Risks & Mitigation

**Risk 1: Filesystem writes survive but row insert fails (or vice-versa) in a partial-failure scenario**
- *Risk*: Crash between filesystem write and row insert leaves an orphan file with no row reference. On next read, `read_tile_pixels` reports a `TileMetadataError` per AC-4, but `total_disk_bytes` doesn't account for the orphan, and the cache budget is silently inflated.
- *Mitigation*: At store construction, an O(N) startup scan reconciles filesystem vs. metadata: orphan files (file present, no row) are deleted on construction with a WARN log naming the path. The companion process restarts on every flight, so the reconciliation runs at known-quiescent boundaries.

**Risk 2: mmap'd `TilePixelHandle` outlives the file**
- *Risk*: A consumer holds the handle past a `delete_tile` call; the underlying fd is invalidated; reading through the mmap raises `BusError`.
- *Mitigation*: `delete_tile` does NOT actively invalidate live mmaps; the OS keeps the fd alive until the consumer's `__exit__`. Documented as a constraint: consumers MUST NOT cache `TilePixelHandle` instances across calls — use them inside a `with` block and release.

**Risk 3: Postgres pool checkout latency spikes under burst**
- *Risk*: 5 Hz F4 burst exhausts the default 4-connection pool; subsequent writes wait, AC-14 fails.
- *Mitigation*: Pool size is config-driven (`config.tile_cache.postgres_pool_size`); benchmarks run at default 4 (which the description's hot-path estimate easily fits); operator can bump if needed. The bench in AC-NFR-perf-pool-checkout pins the regression.

**Risk 4: Compensating delete itself fails**
- *Risk*: After a row insert fails, the compensating filesystem delete also fails (e.g., disk full, permission flip); orphan JPEG persists.
- *Mitigation*: The compensating delete logs ERROR if it fails, but does NOT raise — the original `TileMetadataError` is the operator-visible signal. The reconciliation scan at next start (Risk 1) will clean up. WARN-on-orphan is the steady-state visibility.

**Risk 5: Forward-only voting transitions block legitimate operator overrides**
- *Risk*: Operator decides a TRUSTED tile should go back to PENDING for re-validation; the I-8 invariant blocks them.
- *Mitigation*: I-8 is documented; backward transitions are intentionally a separate operator-tooling concern (delete + re-insert as PENDING is the supported workflow). If operator demand is real, a future contract bump adds a `force_reset_voting` admin method — not in this cycle.

## Runtime Completeness

- **Named capability**: Postgres-backed spatial metadata index + filesystem JPEG store byte-identical to satellite-provider + atomic-write/sidecar via SHA-256 + LRU/voting/upload bookkeeping (description.md / E-C6 / AC-8.1 / AC-8.4 / AC-NEW-3 / AC-NEW-7 / RESTRICT-SAT-2 / D-C10-3).
- **Production code that must exist**: real `PostgresFilesystemStore` class implementing both Protocols; real `mmap`-backed `MmapTilePixelHandle`; real Postgres connection-pool checkout / parameterised query / single-transaction insert / compensating filesystem delete; real path computation via the injected `wgs_converter`; real sha256 sidecar via the injected `sha256_sidecar` helper; real FDR emission via the injected `FdrClient`.
- **Allowed external stubs**: tests MAY use a `testcontainers`-managed Postgres 16, a `tmp_path` filesystem, and fake `FdrClient` / `Logger` / `WgsConverter` (where the wgs_converter fake just returns a fixed path); production wiring uses real implementations from AZ-279 / AZ-280 / AZ-273 / AZ-266.
- **Unacceptable substitutes**: an in-memory dict masquerading as the metadata store (would defeat the byte-identity invariant + the EXPLAIN-plan check + the C6-PT-01 latency benchmark — none would be meaningful); a SQLite shim "for testing only" (test environment must mirror production per coderule.mdc); a path computation that bypasses `wgs_converter` (would duplicate the Web-Mercator math and is the exact byte-identity-drift failure mode the architecture forbids); skipping the compensating delete on row failure (would silently inflate `total_disk_bytes`); a non-rewrapping handler that lets `psycopg.OperationalError` escape (would break the family invariant from AZ-303 § I-2).

## Contract

This task implements the contracts at:
- `_docs/02_document/contracts/c6_tile_cache/tile_store.md`
- `_docs/02_document/contracts/c6_tile_cache/tile_metadata_store.md`

Consumers MUST read those files — not this task spec — to discover the interfaces.
