# Contract: ManifestVerifier (C10)

**Type**: Python Protocol (`@runtime_checkable`) — local in-process API.
**Producer task**: AZ-324_c10_manifest_verifier
**Consumers**:
- C5 State Estimator / takeoff-arming gate (F2 phase) — refuses to arm if `verify_manifest` does not return `outcome=pass`. (E-C5 / AZ-249.)
- C12 Operator Tooling — runs verify before flight handoff to surface drift between F1 build time and F2 takeoff (E-C12 / AZ-253).
- C13 FDR — emits a `manifest.verify` record on every airborne verify call (`outcome` field gates downstream).

## Purpose

`ManifestVerifier` is the read-only validator for the C10-produced cache Manifest. It is the takeoff trust anchor for AC-NEW-1 ("no engine deserialization at takeoff before manifest verify") and D-C10-3 ("SHA-256 content-hash gate over every shipped artifact"). At F2 takeoff, every artifact listed in the Manifest is re-hashed and compared to its recorded digest; any mismatch fails the verdict and prevents arming. The Ed25519 signature over the Manifest is verified against a pinned operator public key before any artifact is touched — defence-in-depth against a spliced Manifest pointing at attacker-chosen content hashes.

## Public Surface

```python
from pathlib import Path
from typing import Protocol, runtime_checkable
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey


@runtime_checkable
class ManifestVerifier(Protocol):
    """Read-only verifier for a C10-produced Manifest.json.

    Fail-closed: any deviation in signature, schema, or per-artifact hash
    yields `VerificationResult(outcome=fail, ...)`. Never raises on a verify
    failure — operators / takeoff arming code branch on `outcome`.

    Raises only on resource errors (Manifest.json missing, key file
    unreadable) — those are environment problems, not verify outcomes.
    """

    def verify_manifest(
        self,
        *,
        manifest_path: Path,
        trusted_public_keys: tuple[Ed25519PublicKey, ...],
    ) -> VerificationResult: ...
```

### DTOs

```python
from dataclasses import dataclass
from enum import Enum
from pathlib import Path


class VerifyOutcome(Enum):
    PASS = "pass"
    FAIL = "fail"


class VerifyFailReason(Enum):
    MANIFEST_NOT_FOUND = "manifest_not_found"
    SIGNATURE_NOT_FOUND = "signature_not_found"
    SIGNATURE_INVALID = "signature_invalid"
    UNTRUSTED_PUBLIC_KEY = "untrusted_public_key"
    SCHEMA_VIOLATION = "schema_violation"
    ARTIFACT_MISSING = "artifact_missing"
    ARTIFACT_HASH_MISMATCH = "artifact_hash_mismatch"
    TILES_COVERAGE_MISMATCH = "tiles_coverage_mismatch"
    MANIFEST_SELF_HASH_MISMATCH = "manifest_self_hash_mismatch"
    TAKEOFF_ORIGIN_INVALID = "takeoff_origin_invalid"          # ADR-010: schema check on the LatLonAlt block
    TAKEOFF_ORIGIN_OUT_OF_BBOX = "takeoff_origin_out_of_bbox"  # ADR-010: origin must lie inside the cache bbox


@dataclass(frozen=True)
class ArtifactCheck:
    relative_path: str
    expected_sha256: str
    actual_sha256: str | None  # None if file missing
    matched: bool


@dataclass(frozen=True)
class VerificationResult:
    outcome: VerifyOutcome
    fail_reasons: tuple[VerifyFailReason, ...]
    fail_details: tuple[str, ...]              # human-readable diagnostic per reason
    signing_public_key_fingerprint: str | None # populated when signature parses, even if untrusted
    per_artifact_checks: tuple[ArtifactCheck, ...]
    takeoff_origin: LatLonAlt | None           # ADR-010 + AZ-490: passed through from Manifest body; None when Manifest carries no origin
    flight_id: UUID | None                     # ADR-010: provenance of which Flight produced the build
    elapsed_ms: int
```

## Invariants

| ID | Invariant | Why |
|----|-----------|-----|
| MV-INV-1 | The verifier is fail-closed: any deviation produces `outcome=FAIL` with at least one `VerifyFailReason`; never returns `PASS` with non-empty `fail_reasons`. | AC-NEW-1 / D-C10-3 — takeoff cannot arm on a partial verify. |
| MV-INV-2 | Signature verification happens BEFORE per-artifact hashing. If the signature is invalid or untrusted, no file content is read beyond the Manifest itself. | Defence-in-depth: a malicious Manifest must not trick the verifier into hashing attacker-chosen file paths. |
| MV-INV-3 | The Manifest's own `Manifest.json.sha256` sidecar (written by AZ-323) must match `sha256(Manifest.json)`; mismatch is `MANIFEST_SELF_HASH_MISMATCH`. | The sidecar is the entry point of the chain of trust — drift here means tampering or atomic-write failure. |
| MV-INV-4 | Per-artifact paths are interpreted relative to `manifest_path.parent`; absolute paths in the Manifest are rejected as `SCHEMA_VIOLATION`. | Prevents a malicious Manifest from pointing outside `cache_root`. |
| MV-INV-5 | `tiles_coverage` mismatch is reported separately from `ARTIFACT_HASH_MISMATCH` because tiles are hashed in aggregate (per AZ-323). The verifier re-derives the aggregate hash from a `TileMetadataStore` query if available, OR (in airborne F2 mode) treats the recorded `tiles_coverage_sha256` as authoritative and only verifies the Manifest signature + non-tile artifacts. | Airborne C5 may not load 100k per-tile rows just to arm; the trust chain is signature → manifest_hash → tiles_coverage_sha256. C12 / operator mode does the full re-derivation. |
| MV-INV-6 | The verifier never writes to disk, never opens network sockets, never calls C13. Telemetry is the caller's responsibility. | Read-only contract — composable in airborne C5 + operator C12 contexts without side-effect surprise. |
| MV-INV-7 | `elapsed_ms` is recorded for every call (pass or fail) so operators and C5 can observe drift in verify cost on slow disks. | NFR for C10-PT-01's takeoff load budget. |
| MV-INV-8 | When the Manifest body carries a `takeoff_origin`, the verifier checks: (a) the LatLonAlt block is well-formed (`-90 ≤ lat ≤ 90`, `-180 ≤ lon ≤ 180`, `alt` finite) → `TAKEOFF_ORIGIN_INVALID` otherwise, (b) the lat/lon falls inside the Manifest's `bbox` → `TAKEOFF_ORIGIN_OUT_OF_BBOX` otherwise. When the Manifest body carries no `takeoff_origin`, the field is absent from `VerificationResult` (None) and no origin check runs. | ADR-010: garbage / out-of-bbox origin must not silently propagate to `C5.set_takeoff_origin`. |
| MV-INV-9 | `takeoff_origin` is surfaced on `VerificationResult` even on `FAIL` outcomes when the Manifest body parsed (so caller can inspect what was attempted), but the takeoff-arming gate only consumes it on `PASS`. | Diagnostics — operators can see "your origin was X and that's why we rejected it". |

## Non-Goals

- **Signature production** — owned by AZ-323's `ManifestSigner`. The verifier never signs.
- **Cache repair** — the verifier reports failures; rebuild is owned by AZ-325 (the orchestrator).
- **Trusted-key distribution / revocation** — `trusted_public_keys` is supplied by the caller; this contract does not define a key registry.
- **Coverage check (orphan files in cache_root)** — owned by AZ-325 (`ManifestCoverageError`); the verifier checks "every Manifest entry exists and matches", not "every cache_root file is in the Manifest".
- **Rollback to prior-good Manifest** — out of scope; caller decides next action on `FAIL`.

## Versioning

- v1.0.0 — initial Protocol surface (this document).
- Breaking changes — adding a required argument, removing a `VerifyFailReason`, changing semantics of an existing one — bump major.
- Additive changes — new `VerifyFailReason` value, new optional kwarg on `verify_manifest`, new field on `VerificationResult` — bump minor. Consumers MUST handle unknown reasons gracefully (default to FAIL).
- Patch — clarifications, doc edits, bug-fix tests.

| Version | Date | Notes | Author |
|---------|------|-------|--------|
| 1.0.0 | 2026-05-10 | Initial contract — produced by AZ-324 (E-C10 decomposition) | autodev |
| 1.1.0 | 2026-05-11 | Additive: `VerificationResult.takeoff_origin` + `flight_id`; new `VerifyFailReason.TAKEOFF_ORIGIN_INVALID` + `TAKEOFF_ORIGIN_OUT_OF_BBOX`; MV-INV-8 + MV-INV-9. ADR-010 + AZ-490. | autodev |

## Test Cases (consumer side)

| ID | Scenario | Expected Outcome |
|----|----------|------------------|
| MV-TC-1 | Valid Manifest + trusted key + all artifacts present + hashes match | `outcome=PASS`, empty `fail_reasons`, `per_artifact_checks` all `matched=True` |
| MV-TC-2 | Manifest.json missing | `outcome=FAIL`, `fail_reasons=(MANIFEST_NOT_FOUND,)`; no further work |
| MV-TC-3 | Manifest.json.sig missing | `outcome=FAIL`, `fail_reasons=(SIGNATURE_NOT_FOUND,)`; signature_public_key_fingerprint=None |
| MV-TC-4 | Signature does not verify | `outcome=FAIL`, `fail_reasons=(SIGNATURE_INVALID,)`; no per-artifact checks performed |
| MV-TC-5 | Signature verifies but key is not in `trusted_public_keys` | `outcome=FAIL`, `fail_reasons=(UNTRUSTED_PUBLIC_KEY,)`; fingerprint populated |
| MV-TC-6 | Schema violation (missing required key, absolute path, wrong types) | `outcome=FAIL`, `fail_reasons=(SCHEMA_VIOLATION,)` with detail naming the field |
| MV-TC-7 | One engine missing on disk | `outcome=FAIL`, `fail_reasons=(ARTIFACT_MISSING,)`; `per_artifact_checks` shows that engine with `actual_sha256=None, matched=False` |
| MV-TC-8 | One engine present but bytes drifted | `outcome=FAIL`, `fail_reasons=(ARTIFACT_HASH_MISMATCH,)`; offending check has `matched=False` |
| MV-TC-9 | Multiple failures (missing + drifted + signature OK) | `fail_reasons` contains BOTH `ARTIFACT_MISSING` and `ARTIFACT_HASH_MISMATCH`; per-artifact checks complete (don't short-circuit on first failure) |
| MV-TC-10 | `Manifest.json.sha256` sidecar mismatch | `outcome=FAIL`, `fail_reasons=(MANIFEST_SELF_HASH_MISMATCH,)`; signature path NOT consulted |
| MV-TC-11 | Tampered Manifest body but matching sidecar | `outcome=FAIL`, `fail_reasons=(SIGNATURE_INVALID,)` (the signature cannot match if body changed even by 1 byte) |
| MV-TC-12 | Conformance: `isinstance(ManifestVerifier, my_impl)` | `True` |
| MV-TC-13 | Tier-2 Tile-coverage check (operator mode with TileMetadataStore) | If recomputed `tiles_coverage_sha256` differs → `TILES_COVERAGE_MISMATCH`; if matches → that part passes |
| MV-TC-14 | Empty `trusted_public_keys` | `outcome=FAIL`, `fail_reasons=(UNTRUSTED_PUBLIC_KEY,)` (every key is untrusted by definition) |
| MV-TC-15 | Pristine Manifest verified inside 100 ms on Tier-2 (excludes per-tile re-walk) | `elapsed_ms ≤ 100` for the signature + non-tile artifact path |
| MV-TC-16 | Manifest body carries no `takeoff_origin` | `outcome=PASS`; `VerificationResult.takeoff_origin is None` |
| MV-TC-17 | Manifest body carries a well-formed `takeoff_origin` inside bbox | `outcome=PASS`; `VerificationResult.takeoff_origin` populated as `LatLonAlt` |
| MV-TC-18 | Manifest body carries a malformed `takeoff_origin` (lat = 200) | `outcome=FAIL`, `fail_reasons` contains `TAKEOFF_ORIGIN_INVALID`; `takeoff_origin` field is populated for diagnostics |
| MV-TC-19 | Manifest body carries `takeoff_origin` outside the recorded `bbox` | `outcome=FAIL`, `fail_reasons` contains `TAKEOFF_ORIGIN_OUT_OF_BBOX` |
