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