# Implementation Report — Cycle 6

**Cycle**: 6
**Date**: 2026-05-12
**Tasks shipped**: AZ-505 (batch 1)
**Verdict**: PASS (Product Implementation Completeness Gate)

## Summary

Cycle 6 ships **the consumer-facing payload of the AZ-503-foundation tile identity work** — the deliverables that were intentionally split out at the end of cycle 5 (`_docs/02_tasks/done/AZ-503_tile_identity_uuidv5_bulk_list.md` § "Scope split note"). With cycle 6, the AZ-503 epic's external surface is now feature-complete:

- **`POST /api/satellite/tiles/inventory`** — bulk-list / pre-flight cache sizing endpoint that the onboard `TileDownloader` (sibling repo `gps-denied-onboard` AZ-316) is gated behind `c11.use_bulk_list_endpoint=false` until this PBI lands in the target environment.
- **`tiles_leaflet_path` covering index** — makes the Leaflet hot path (`GET /tiles/{z}/{x}/{y}`) an `Index Only Scan` against `(location_hash, captured_at DESC, updated_at DESC, id DESC) INCLUDE (file_path, source)`. `GetByTileCoordinatesAsync` was rewired to filter on `location_hash` (deterministic UUIDv5) to drive the index; behaviour is byte-identical.
- **Kestrel HTTP/2 (TLS + ALPN)** — `Http1AndHttp2` on the dev listener, now bound to `https://+:8080` with a self-signed cert at `./certs/api.pfx` (generated by `scripts/run-tests.sh`; gitignored). Kestrel requires TLS for HTTP/2 protocol negotiation; ALPN advertises both `h2` and `http/1.1` so programmatic clients (httpx `http2=True`, .NET `HttpClient` with `Version20` + `RequestVersionExact`) and HTTP/2-capable browsers all multiplex tile reads on a single TLS connection. The integration-test container trusts the dev cert via `/usr/local/share/ca-certificates/` + `update-ca-certificates`. (Original plan was h2c on plaintext 8080; switched mid-cycle when Kestrel logged `HTTP/2 is not enabled for [::]:8080 ... TLS is not enabled`. See "Post-merge correction" below.)
- **Contract artifacts** — new `tile-inventory.md` v1.0.0 and the long-deferred `tile-storage.md` v2.0.0 major bump (architecture.md had named AZ-505 as owner since cycle 5).

## Batches

| Batch | Tasks | Verdict | Report |
|-------|-------|---------|--------|
| 1 | AZ-505 — Tile inventory + HTTP/2 + Leaflet covering index | PASS | `batch_01_cycle6_report.md` (review: `reviews/batch_01_cycle6_review.md`) |

Code review accepted PASS after one auto-fix round (consolidated `ComputeLocationHash` duplication into the new `Uuidv5.LocationHashForTile` helper in `SatelliteProvider.Common.Utils`).

## Code Changes

### AZ-505 — Tile inventory + HTTP/2 + Leaflet covering index

**Schema** (`SatelliteProvider.DataAccess/Migrations/015_AddTilesLeafletPathIndex.sql`):
- `CREATE INDEX tiles_leaflet_path ON tiles (location_hash, captured_at DESC, updated_at DESC, id DESC) INCLUDE (file_path, source)` — leaflet hot path.
- `DROP INDEX IF EXISTS idx_tiles_location_hash` — superseded by the leading column of the covering index.
- Migration header documents the lock window (DbUp-incompatible with `CREATE INDEX CONCURRENTLY`) and the INCLUDE-column rationale per AZ-505 Risk 1 + Risk 2.

**Application code**:
- `SatelliteProvider.Common/Utils/Uuidv5.cs` — added `LocationHashForTile(int z, int x, int y)`; one source-of-truth for the cross-repo `UUIDv5(TileNamespace, "{z}/{x}/{y}")` formula consumed by repository, service, and tests.
- `SatelliteProvider.Common/DTO/TileInventory.cs` (new) — `TileInventoryRequest` (XOR `Tiles` / `LocationHashes`), `TileCoord`, `TileInventoryResponse`, `TileInventoryEntry`, `TileInventoryLimits.MaxEntriesPerRequest = 5000`.
- `SatelliteProvider.Common/Interfaces/ITileService.cs` — added `GetInventoryAsync(TileInventoryRequest, CancellationToken)`.
- `SatelliteProvider.DataAccess/Repositories/ITileRepository.cs` — added `GetTilesByLocationHashesAsync(IReadOnlyList<Guid>) → Task<IReadOnlyDictionary<Guid, TileEntity>>`.
- `SatelliteProvider.DataAccess/Repositories/TileRepository.cs` — `GetByTileCoordinatesAsync` rewired to filter on `location_hash` (index-only-scannable against `tiles_leaflet_path`); `GetTilesByLocationHashesAsync` uses Npgsql-direct `NpgsqlCommand` with `NpgsqlDbType.Array | Uuid` parameter binding (Dapper's `IEnumerable` expansion is incompatible with `ANY($1::uuid[])`).
- `SatelliteProvider.Services.TileDownloader/TileService.cs` — `GetInventoryAsync` owns the request → hash → repo → response shaping; ordering invariant + Form-A / Form-B handling per the contract.
- `SatelliteProvider.Api/Program.cs` — `app.MapPost("/api/satellite/tiles/inventory", GetTilesInventory).RequireAuthorization().Accepts<TileInventoryRequest>("application/json").Produces<TileInventoryResponse>(200).ProducesProblem(400)` with OpenAPI Description pointing at the contract. Kestrel `Http1AndHttp2` on every endpoint via `builder.WebHost.ConfigureKestrel(...)`. Inline `GetTilesInventory` handler enforces the body shape XOR + 5000-cap before delegating to `ITileService.GetInventoryAsync`.

## Test Changes

- `SatelliteProvider.IntegrationTests/TileInventoryTests.cs` (new) — AC-1, AC-2, AC-4, AC-6.
- `SatelliteProvider.IntegrationTests/Http2MultiplexingTests.cs` (new) — AC-5. Sets the process-wide `Http2UnencryptedSupport` AppContext switch; runs early in `Main` so the switch is hot before any other test creates an HttpClient.
- `SatelliteProvider.IntegrationTests/LeafletPathIndexOnlyTests.cs` (new) — AC-3. 100k-row fixture in full mode, 10k in smoke, plus a fall-back to `SET enable_seqscan = off` on tiny smoke fixtures.
- `SatelliteProvider.IntegrationTests/Program.cs` — wired the three new entry points into both `RunSmokeSuite` and `RunFullSuite`.

## Documentation Changes

- `_docs/02_document/contracts/api/tile-inventory.md` v1.0.0 (new).
- `_docs/02_document/contracts/data-access/tile-storage.md` v1.0.0 → v2.0.0 major bump (joint freeze of AZ-503-foundation + AZ-505).
- `_docs/02_document/architecture.md`, `_docs/02_document/module-layout.md`, `_docs/02_document/glossary.md`, `_docs/02_document/data_model.md`, `_docs/02_document/modules/api_program.md`, `_docs/02_document/modules/dataaccess_tile_repository.md`, `_docs/02_document/components/02_data_access/description.md` — endpoint / repo method rows, query / index table updates, Location Hash promotion, AZ-505 cross-references.

## AC Coverage

| AC | Status | Test |
|----|--------|------|
| AC-1 Inventory returns one entry per request entry in input order | Covered | `TileInventoryTests.OrderingAndPresentAbsentShaping_AC1` |
| AC-2 Leaflet path returns most-recent variant via `location_hash` | Covered | `TileInventoryTests.LeafletReadReturnsMostRecentViaLocationHash_AC2` (DB-level verification of the exact SELECT used by `GetByTileCoordinatesAsync`; ServeTile is a wrapper around the row read) |
| AC-3 Leaflet hot path uses `Index Only Scan using tiles_leaflet_path` | Covered | `LeafletPathIndexOnlyTests.RunAll` (EXPLAIN ANALYZE + regex + Heap Fetches ≤ 1) |
| AC-4 Inventory p95 ≤ 1000 ms for 2500 tiles | Covered | `TileInventoryTests.PerformanceBudget_AC4` (full-suite only; smoke prints documented skip) |
| AC-5 HTTP/2 multiplexed responses on the dev TLS endpoint (ALPN-negotiated) | Covered | `Http2MultiplexingTests.RunAll` |
| AC-6 Request validation: 400 both-populated / 400 neither / 400 > 5000 / 401 anonymous | Covered | `TileInventoryTests.ValidationRejects{BothPopulated,NeitherPopulated,OversizedBatch}_AC6` + `TileInventoryTests.UnauthenticatedRequestReturns401_AC6` |
| AC-7 Contract artifacts produced in the same commit | Covered (doc-only) | `tile-inventory.md` v1.0.0 + `tile-storage.md` v2.0.0 Change Log entry + module-layout / glossary / data_model / module-doc updates |

**All 7 ACs covered.** No deferrals, no in-scope test gaps.

## Completeness Gate

`_docs/03_implementation/implementation_completeness_cycle6_report.md` — **PASS**. Every AZ-505 promise is implemented as production behaviour; named runtime dependencies (Npgsql array-typed binding, Kestrel HTTP/2, `pgcrypto` already enabled by migration 014) are integrated; no scaffold / placeholder / native-bridge markers introduced.

## Handoff to autodev Step 11 (Run Tests)

Per `/implement` Step 16: since the next existing-code flow step is **Run Tests**, the implement skill does **not** run the full suite again. The `test-run` skill owns the full-suite gate to avoid duplicate runs.

Recommendation for `test-run`:

- Full integration-test suite runs via `docker-compose -f docker-compose.yml -f docker-compose.tests.yml up --build --abort-on-container-exit` (per `AGENTS.md`). The three new test files are wired into both `RunSmokeSuite` and `RunFullSuite`, plus `Http2MultiplexingTests` is called early in `Main` so it always runs.
- `LeafletPathIndexOnlyTests` seeds a meaningful number of rows (10k smoke / 100k full) and runs `VACUUM ANALYZE` mid-test. On the smoke run it falls back to `SET enable_seqscan = off` if the optimiser hasn't picked the index naturally — the assertion measures index *capability*, not optimiser heuristic, and the spec explicitly allows this.
- `TileInventoryTests.PerformanceBudget_AC4` is full-suite only; smoke prints a documented skip line.
- `Http2MultiplexingTests` requires the API + DB containers to be up before the test process starts (`AppContext.SetSwitch` happens inside the test's first method). The existing test harness already waits on the API health probe before running.
- If the DNS-flake from cycle 5 recurs against `mt1.google.com` / `tile.googleapis.com`, treat it as the same host-network flakiness — out of scope for AZ-505 (this PBI does not touch the Google Maps download path).

## Git

- Branch: `dev`
- Auto-push: enabled this session (`auto_push: true` in `_docs/_autodev_state.md`)
- Commits pushed (subject lines):
  - `[AZ-505] Tile inventory + HTTP/2 + leaflet covering index`
  - `[AZ-505] AC-5 fix: enable TLS for HTTP/2 via ALPN`  (post-merge correction — see below)

## Post-merge correction (Run Tests step)

The initial cycle-6 commit configured Kestrel as `HttpProtocols.Http1AndHttp2` on a plaintext listener (`http://+:8080`). The `Http2MultiplexingTests` regression run revealed Kestrel logs `HTTP/2 is not enabled for [::]:8080 ... TLS is not enabled` and silently falls back to HTTP/1.1 only — ALPN cannot run over plaintext, so the protocol-multiplex semantics AC-5 verifies never engaged. The fix:

- Generate a self-signed dev cert (`./certs/api.pfx` + `./certs/api.crt`) idempotently from `scripts/run-tests.sh` (`ensure_dev_cert` block using `openssl` inside an `alpine` container). `certs/` is gitignored.
- Switch the API listener to `https://+:8080` in `docker-compose.yml` (`ASPNETCORE_URLS`, `ASPNETCORE_Kestrel__Certificates__Default__Path`, `__Password`) and mount the `.pfx` read-only.
- In `docker-compose.tests.yml`, mount `./certs/api.crt` into `/usr/local/share/ca-certificates/` of the integration-tests container and run `update-ca-certificates` in the entrypoint so `HttpClient` trusts the dev cert with no per-test handler tweaks. Test default `API_URL` updated to `https://api:8080`.
- Drop the `AppContext.SetSwitch("System.Net.SocketsHttpHandler.Http2UnencryptedSupport", true)` line from `Http2MultiplexingTests.cs` — no longer needed once ALPN over TLS does the negotiation.

While re-running the suite, two test-data issues in the new tests also surfaced and were fixed in the same correction commit (they are independent of the TLS switch, both about Npgsql v6+ refusing `DateTime.Kind=Utc` for `timestamp without time zone` columns, both seen first on the post-TLS rerun):

- `Http2MultiplexingTests` was hardcoding slippy coords `(z=18, x=154321, y=95812)` that Google Maps returns 404 for. Switched to `(158485, 91707)` — the slippy projection of `(47.461747, 37.647063)` already exercised by `JwtIntegrationTests`.
- `TileInventoryTests.PerformanceBudget_AC4`, `TileInventoryTests.SeedTileAsync`, and `LeafletPathIndexOnlyTests` were binding `DateTime.UtcNow` (Kind=Utc) into the `tiles.captured_at` / `created_at` / `updated_at` `TIMESTAMP` columns via raw `NpgsqlCommand` (production goes through Dapper, which never hits this code path). `DateTime.SpecifyKind(..., Unspecified)` applied at the binding site fixes the test seed without altering the production write path.
- `MigrationTests.Az503NewUniqueIndexCoversIntegerKeyAndFlightId` was hardcoded to look for `idx_tiles_location_hash` from migration 014; migration 015 explicitly drops that index because the new covering index `tiles_leaflet_path` has the same leading column. Assertion broadened to accept either index name so the AZ-503 AC-9 intent ("a location_hash-indexed access path exists") stays verifiable.

After the correction commit the full integration test suite passes (mode=full, exit 0).

## Open Items

- **Leftover replay** — `_docs/_process_leftovers/2026-05-12_perf-cycle3-harness-execution.md` remains open. AZ-505 did NOT touch `scripts/run-performance-tests.sh` per its Excluded scope; the leftover's unblocking work (DNS pre-warmup in the script + cloud-side perf rerun) is a separate PBI candidate.
- **Cross-repo**: `gps-denied-onboard` AZ-316 (onboard `TileDownloader`) needs to flip `c11.use_bulk_list_endpoint=true` once this PBI is deployed to its target environment. Sibling-repo concern; not in this workspace's deliverables.
- **HTTP/3 / QUIC** — deferred per AZ-505 Excluded list (ALPN + UDP plumbing not verified through dev compose).
- **PMTiles / multipart bundle endpoint** — rejected by AZ-503 parent rationale (HTTP/2 multistream is sufficient).
- **`estimatedBytes` on inventory response** — deferred until production profiling justifies the per-row `stat()` cost (AZ-505 Outcome bullet 1).
- **`tiles_leaflet_path` INCLUDE column widening** — out of scope per AZ-505 Risk 1; revisit only if production profiling shows inventory heap-fetch cost is the bottleneck.
- **Cycle-4 carry-overs** still pending (each is a separate PBI candidate, none are AZ-505 scope per its Excluded list): `Microsoft.NET.Test.Sdk` 17.8.0 transitive advisory, `Microsoft.IdentityModel.Tokens` / `System.IdentityModel.Tokens.Jwt` 7.0.3 → 7.1.2+ bump, `ASPDEPR002` `WithOpenApi(...)` migration, `Serilog.AspNetCore` 10.x recheck.
