# Contract: tile_uploader

**Component**: c11_tilemanager
**Producer task**: AZ-319_c11_tile_uploader
**Consumer tasks**: AZ-253 (E-C12 Operator Pre-flight Tooling — TBD at C12 decompose time)
**Version**: 1.0.0
**Status**: draft
**Last Updated**: 2026-05-10

## Purpose

The `TileUploader` Protocol is C11's operator-side post-landing upload interface. C12 invokes it during F10 (post-landing) to read mid-flight tiles flagged pending-upload from C6 (`source = onboard_ingest`, `voting_status = pending`), package them per the D-PROJ-2 ingest contract sketch, sign each tile payload with the per-flight ephemeral key (AZ-318), and POST to `satellite-provider`'s `/api/satellite/tiles/ingest` endpoint. Acknowledged tiles are marked uploaded in C6.

The uploader gates on `flight_state == ON_GROUND` (AZ-317) before any network egress. C11 is operator-side ONLY; ADR-004 forbids the airborne companion image from importing this module.

## Shape

### Function / method API

```python
from typing import Protocol, runtime_checkable

@runtime_checkable
class TileUploader(Protocol):
    def upload_pending_tiles(self, request: UploadRequest) -> UploadBatchReport: ...
    def enumerate_pending_tiles(self, flight_id: uuid.UUID | None = None) -> list[TileMetadata]: ...
    def confirm_flight_state(self) -> FlightStateSignal: ...
```

| Name | Signature | Throws / Errors | Blocking? |
|------|-----------|-----------------|-----------|
| `upload_pending_tiles` | `(request: UploadRequest) -> UploadBatchReport` | `FlightStateNotOnGroundError`, `SatelliteProviderError`, `RateLimitedError`, `SignatureRejectedError`, `TileMetadataError` | sync (post-landing; minutes) |
| `enumerate_pending_tiles` | `(flight_id: uuid.UUID \| None) -> list[TileMetadata]` | `TileMetadataError` | sync (seconds) |
| `confirm_flight_state` | `() -> FlightStateSignal` | `FlightStateNotOnGroundError` | sync (≤ 1 ms) |

### Data DTOs

```python
@dataclass(frozen=True)
class UploadRequest:
    flight_id:                  uuid.UUID | None         # None = all flights with pending
    batch_size:                 int                       # tiles per HTTP POST
    satellite_provider_url:     str                       # parent-suite ingest base URL

@dataclass(frozen=True)
class UploadBatchReport:
    batch_uuid:                 uuid.UUID                 # assigned by parent-suite ingest
    per_tile_status:            tuple[PerTileStatus, ...]
    retry_count:                int
    next_retry_at_s:            int | None                # set when partial-success
    outcome:                    UploadOutcome             # success | partial | failure
    public_key_fingerprint:     str                       # 16-hex; from AZ-318

@dataclass(frozen=True)
class PerTileStatus:
    tile_id:                    TileId                    # from c6_tile_cache
    status:                     IngestStatus              # queued | rejected | duplicate | superseded
    rejection_reason:           str | None
```

| Field | Type | Required | Description | Constraints |
|-------|------|----------|-------------|-------------|
| `UploadRequest.flight_id` | `UUID \| None` | no | Restricts batch to one flight | None = all pending across flights |
| `UploadRequest.batch_size` | `int` | yes | Tiles per HTTP POST | `1 ≤ batch_size ≤ 200` |
| `UploadBatchReport.batch_uuid` | `UUID` | yes | Parent-suite batch identifier | Server-assigned per D-PROJ-2 |
| `UploadBatchReport.per_tile_status` | `tuple[PerTileStatus, ...]` | yes | Per-tile result | Length = number of tiles attempted in this report |
| `UploadBatchReport.outcome` | `UploadOutcome` | yes | Aggregate outcome | `success` (all queued/duplicate/superseded) \| `partial` (some rejected/timeout) \| `failure` (gate blocked or full failure) |
| `UploadBatchReport.public_key_fingerprint` | `str` | yes | Identifies the per-flight signing key | 16 hex chars from AZ-318 |
| `PerTileStatus.status` | `IngestStatus` | yes | Server response status | `queued` \| `rejected` \| `duplicate` \| `superseded` |

## Invariants

- I-1: `confirm_flight_state` is called by `upload_pending_tiles` BEFORE any C6 read or network egress; if `FlightStateNotOnGroundError` is raised, NO tiles are read, NO POSTs are issued, NO C6 mutation occurs. The gate is closed by default.
- I-2: Every uploaded tile carries a signature produced by the AZ-318 per-flight key manager's `sign(payload)`. The parent suite verifies against the public key it received via the safety officer's pre-flight enrolment OR the `kind="c11.upload.session.key.public"` FDR record.
- I-3: A tile acknowledged as `queued`, `duplicate`, or `superseded` by the parent suite is marked `uploaded` in C6 (`mark_uploaded(tile_id)`); a tile acknowledged as `rejected` is NOT marked uploaded — it remains `pending` for human review.
- I-4: The per-flight signing key is zeroised at the end of `upload_pending_tiles` regardless of success or failure (try/finally in the caller; AZ-318's `end_session()`).
- I-5: A `SignatureRejectedError` from the parent suite triggers an FDR alert (AZ-318's `record_signature_rejection`); it is NEVER silently caught.
- I-6: The uploader writes via the AZ-303 `TileMetadataStore.mark_uploaded` Protocol; it does NOT update the metadata table directly.
- I-7: Partial-success batches are reported (not raised as failures) so the caller can re-invoke for the unacked tiles; idempotent retry behaviour is owned by the AZ-320 decorator that wraps this Protocol's impl.
- I-8: The signed payload includes `capture_timestamp` per the D-PROJ-2 contract sketch; the parent suite's nonce / timestamp validation owns replay defence.

## Non-Goals

- Not covered: airborne or in-flight uploads (RESTRICT-SAT-1 forbids them; airborne process cannot import this module per ADR-004).
- Not covered: orchestration of when the operator runs F10 — owned by C12.
- Not covered: tile downloads from `satellite-provider` — owned by `TileDownloader` (separate contract).
- Not covered: parent-suite voting / trust-promotion of uploaded tiles — owned by D-PROJ-2 design task #2 (`satellite-provider`).
- Not covered: HSM / TPM-backed key storage — out of scope this cycle (in-memory key with zeroisation).
- Not covered: mid-upload key rotation — one key per session.
- Not covered: idempotent retry across partial-success batches — separate task in this epic decorates this contract.

## Versioning Rules

- **Breaking changes** (renamed method, removed required field, changed return type, changed signature contract) require a major version bump. C12 is the sole consumer today; coordinate via Choose A/B/C/D when bumping.
- **Non-breaking additions** (new optional field on the report, new `IngestStatus` enum value the consumer already tolerates via `_ = status`) require a minor version bump.

## Test Cases

| Case | Input | Expected | Notes |
|------|-------|----------|-------|
| upload-happy-path | 50 pending tiles, ON_GROUND, parent-suite returns 202 with all `queued` | `UploadBatchReport.outcome = success`; all 50 marked `uploaded` in C6; signature verifies on each | C11-IT-03 |
| flight-state-blocks | `FlightStateSource` returns `IN_FLIGHT` | `FlightStateNotOnGroundError`; zero C6 reads; zero POSTs | C11-IT-04 |
| signature-rejected | Parent suite returns `rejected` for 1 tile with reason `"invalid signature"` | `PerTileStatus.status = rejected`; `outcome = partial`; FDR `c11.upload.signature_rejected` emitted; the tile NOT marked uploaded | I-5 |
| duplicate-acknowledged | Parent suite returns `duplicate` for 5 tiles (already ingested in a prior batch) | All 5 marked `uploaded`; `outcome = success` | I-3 |
| signing-key-zeroised | Run a successful upload, then assert the AZ-318 manager's `_private_key is None` | Always zeroised; FDR `c11.upload.session.key.zeroised` recorded | I-4 |
| signing-key-zeroised-on-failure | Network drop mid-batch raises `SatelliteProviderError`, then assert key zeroised | Always zeroised even on failure | I-4 |
| empty-pending-set | No pending tiles | `outcome = success` with empty `per_tile_status`; zero POSTs; zero key generation | edge case |
| public-key-in-fdr-before-first-post | Capture FDR records | `kind="c11.upload.session.key.public"` precedes any `c11.upload.tile.*` records | safety-officer correlation |

## Change Log

| Version | Date | Change | Author |
|---------|------|--------|--------|
| 1.0.0 | 2026-05-10 | Initial contract — produced by AZ-319 (E-C11 decomposition) | autodev |
