# 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" @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, ...] 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. | ## 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 | ## 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 |