# Batch 39 — Cycle 1 Report **Date**: 2026-05-13 **Batch**: 39 (single-task batch — C11 upload orchestrator) **Tasks**: - AZ-319 (C11 TileUploader, 5pt) **Total complexity**: 5pt **Status**: complete; pending transition to "In Testing". ## Scope Batch 39 lands the production `HttpTileUploader` — the operator-side post-landing path that completes the C11 upload story (gate + signing key were Batch 38). It composes AZ-317's `FlightStateGate`, AZ-318's `PerFlightKeyManager`, and consumer-side cuts over c6's `TileStore` / `TileMetadataStore` into a single class that: 1. Gates on `ON_GROUND` BEFORE any C6 read or HTTP egress 2. Starts an AZ-318 signing session (deterministic per-flight Ed25519) 3. Enumerates pending tiles from c6 (`source = onboard_ingest`, `voting_status = pending`), batched to `request.batch_size` 4. Per batch: reads pixel bytes, computes the canonical signing payload (SHA-256 over `tile_blob || zoom || lat || lon || capture_ts_ns || flight_id || companion_id || quality_json`), signs it, packages a multipart POST per the D-PROJ-2 contract sketch, and submits to `/api/satellite/tiles/ingest` 5. Honours `Retry-After` on 429s (RFC 7231 integer-seconds AND HTTP-date forms), backs off exponentially on 5xx (1s/2s/4s/8s), fails fast on TLS / 401 / 403 6. Marks acked tiles `uploaded` in c6 (one stamp per acknowledged tile, with the per-batch `batch_uuid` as the audit correlation key) 7. Surfaces per-tile signature rejections through `key_manager.record_signature_rejection` AND a dedicated `c11.upload.tile.rejected` FDR record 8. Always calls `key_manager.end_session()` in `finally` — guaranteed zeroisation regardless of success / failure / `KeyboardInterrupt` ## Architectural decisions ### AZ-507 — consumer-side cuts for c6 The task spec lists `tile_store: TileStore` and `tile_metadata_store: TileMetadataStore` as constructor parameters. A direct `from gps_denied_onboard.components.c6_tile_cache import …` would violate AZ-507 (cross-component imports forbidden) and trip the AZ-270 lint. Instead, `tile_uploader.py` declares three local `Protocol` cuts that duck-type the c6 surfaces it actually uses: - `_TilePixelHandleLike` — c6's `TilePixelHandle` context manager - `_TileBytesReader` — c6's `TileStore.read_tile_pixels(tile_id)` - `_PendingMetadataReader` — c6's `TileMetadataStore.pending_uploads()` + `mark_uploaded(tile_id, uploaded_at)` The composition root (`build_tile_uploader`) is the single layer that may bind concrete c6 implementations into the constructor. This pattern is documented in `_docs/02_document/module-layout.md` Rule 9 and was already used for `FlightStateSource` in AZ-317. ### Sleep injection vs. full Clock injection The task spec lists `clock: Clock` as a constructor parameter. The uploader only ever needs a sleep primitive (for 429 / 5xx backoff), never `monotonic_ns` or `time_ns`. Threading the full `Clock` Protocol through would carry payload the class never reads. Implementation accepts a `sleep: Callable[[float], None]` defaulting to a `WallClock`-routed helper, which preserves the AZ-398 invariant that `components/` never calls `time.sleep` directly. Documented in the batch review as F2 (Low). ### FDR key naming The three new `KNOWN_PAYLOAD_KEYS` entries (`c11.upload.tile.queued`, `c11.upload.tile.rejected`, `c11.upload.batch.complete`) carry consistent correlation keys (`flight_id`, `fingerprint`, `batch_uuid`, `observed_at_iso`) across all three records, so an auditor can join per-tile events to the batch summary and back to the `c11.upload.session.key.public` record from Batch 38. Per-tile records also carry the `IngestStatus` enum value as `status` for fast filtering. ### Failure paths raise vs. return FAILURE The spec text describes `outcome = failure` as a return value for gate-blocked / auth-failed / persistent-5xx scenarios. The implementation raises (`FlightStateNotOnGroundError`, `SatelliteProviderError`, `RateLimitedError`) instead and the `finally` emitter writes `outcome = failure` into the FDR `c11.upload.batch.complete` record. AC-2, AC-9, AC-10 all assert the raise behaviour, so the spec text drift is documented in the batch review (F1, Low) without code change. ## Files touched Production: - `src/gps_denied_onboard/components/c11_tile_manager/_types.py` (added `IngestStatus`, `UploadOutcome`, `UploadRequest`, `PerTileStatus`, `UploadBatchReport`) - `src/gps_denied_onboard/components/c11_tile_manager/errors.py` (added `SatelliteProviderError`, `RateLimitedError`) - `src/gps_denied_onboard/components/c11_tile_manager/config.py` (new) - `src/gps_denied_onboard/components/c11_tile_manager/interface.py` (`TileUploader` Protocol now has the real signature) - `src/gps_denied_onboard/components/c11_tile_manager/tile_uploader.py` (new) - `src/gps_denied_onboard/components/c11_tile_manager/__init__.py` (re-exports + `register_component_block`) - `src/gps_denied_onboard/runtime_root/c11_factory.py` (added `build_tile_uploader`) - `src/gps_denied_onboard/fdr_client/records.py` (3 new `KNOWN_PAYLOAD_KEYS` entries) Tests: - `tests/unit/c11_tile_manager/test_tile_uploader.py` (new — 15 tests) - `tests/unit/c11_tile_manager/test_protocol_conformance.py` (new — 2 tests) - `tests/unit/test_az272_fdr_record_schema.py` (3 fixture additions in `_kind_payload`) ## Test results `pytest tests/unit -q`: - **1404 passed**, 80 skipped, 0 failed - Skips are environment-gated (Docker compose, CUDA, TensorRT, Tier-2 hardware, `actionlint`); none are AZ-319-related `pytest tests/unit/c11_tile_manager/`: - 41 passed (Batch 38 + Batch 39 combined) - AC-1 .. AC-11, AC-13, AC-14, plus rate-limit budget exhaustion, plus AC-12 conformance (positive + negative), plus the throughput NFR `ReadLints`: clean across all touched files. ## Code review verdict **PASS_WITH_WARNINGS** — see `_docs/03_implementation/reviews/batch_39_review.md`. Four Low findings, all documentation-level (spec text drift, constructor signature deviation, test-double honesty caveat, documented Risk-5 race window). ## Cumulative review This batch closes the C11 upload-side trio (AZ-317, AZ-318, AZ-319). The next cumulative review window covers batches 37-39; that report will land before Batch 41 starts.