// Copyright 2026 AlphaOne LLC
// SPDX-License-Identifier: Apache-2.0

//! Inbound Ed25519 verification for federated `memory_links` (Track H,
//! Task H3).
//!
//! Builds on H1 ([`crate::identity::keypair`]) and H2
//! ([`crate::identity::sign`]). H2 sealed the canonical CBOR encoding and
//! the outbound signing path; this module is the mirror — when a link
//! arrives from a peer over `sync_push`, we re-derive the same canonical
//! CBOR bytes and verify the 64-byte signature against the public key
//! associated with the link's `observed_by` claim.
//!
//! # Trust model
//!
//! - The peer's public key is read from the *receiver's* on-disk key
//!   directory ([`crate::identity::keypair::default_key_dir`]) — i.e. the
//!   peer was previously enrolled (`identity import` or `identity
//!   generate` for a peer agent_id) by this host's operator. This keeps
//!   the trust root local: a peer cannot upgrade its own attest_level by
//!   sending us a fresh public key.
//! - If `observed_by` has no enrolled key on this host, the link is still
//!   accepted (`attest_level = "unsigned"`) so federation back-compat
//!   holds for peers that haven't enrolled yet. This degraded posture is
//!   intentional — H3 brings opt-in attestation, not a hard cutover.
//! - If the peer *is* enrolled and the signature does not verify, the
//!   link is rejected with a `tracing::warn!` log line. Tampered or
//!   forged inbound links never land in the receiver's `memory_links`
//!   table.
//!
//! # Out of scope here
//!
//! - `attest_level` enum + `memory_verify` MCP tool (H4). H3 stays on
//!   the existing TEXT column with the literal `"peer_attested"` /
//!   `"unsigned"` strings already documented in [`crate::db`].
//! - `signed_events` audit table (H5).
//! - End-to-end federation integration test (H6).

use std::path::Path;

use ed25519_dalek::{Signature, VerifyingKey};

use crate::identity::keypair;
use crate::identity::sign::{
    SignableCheckpointResolution, SignableLink, SignableRoutineFreeze, SignableSignal,
    SignableTransition, SignableWrite, canonical_cbor, canonical_cbor_checkpoint_resolution,
    canonical_cbor_routine_freeze, canonical_cbor_signal, canonical_cbor_transition,
    canonical_cbor_write,
};

/// Length of an Ed25519 signature in bytes. Mirrors the constant
/// [`ed25519_dalek::SIGNATURE_LENGTH`] but pinned locally so the verify
/// path doesn't pull a pub-use dependency on the crate's surface.
pub const SIGNATURE_LEN: usize = ed25519_dalek::SIGNATURE_LENGTH;

/// Outcome of an inbound verify attempt.
///
/// Hand-rolled `Display` + `Error` (no `thiserror`) per repo convention:
/// the OSS substrate keeps its dependency surface deliberately small so
/// the AgenticMem commercial layer can lift the same error shape without
/// re-vendoring proc-macros.
#[derive(Debug, PartialEq, Eq)]
pub enum VerifyError {
    /// Signature did not validate against the supplied public key over
    /// the link's canonical CBOR. Either the link content was tampered
    /// with in flight, the signature bytes themselves were flipped, or
    /// the wrong public key was supplied for `observed_by`.
    Tampered,
    /// `lookup_peer_public_key` returned `None` — the receiver has no
    /// enrolled key for `observed_by`. Callers may choose to treat this
    /// as accept-and-flag-as-unsigned (the federation inbound path) or
    /// as a hard reject (a future strict-mode operator opt-in).
    NoPublicKey,
    /// The supplied signature blob was not exactly 64 bytes — Ed25519
    /// signatures are fixed-length, so any other length is structurally
    /// invalid before we even try the verify.
    MalformedSignature,
}

impl std::fmt::Display for VerifyError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Tampered => f.write_str(
                "Ed25519 signature did not validate against the supplied public key — \
                 link content or signature bytes do not match what observed_by signed",
            ),
            Self::NoPublicKey => {
                f.write_str("no public key enrolled for observed_by — receiver cannot verify")
            }
            Self::MalformedSignature => f.write_str(
                "signature is not exactly 64 bytes — not a well-formed Ed25519 signature",
            ),
        }
    }
}

impl std::error::Error for VerifyError {}

/// Verify `signature` over the canonical CBOR encoding of `link` using
/// `public`.
///
/// The verifier re-derives the exact byte sequence H2's
/// [`crate::identity::sign::sign`] hashed before signing. Any divergence
/// between the inbound link content and what the peer originally signed
/// — even a single byte flip in `relation`, `observed_by`, etc. —
/// changes the CBOR output and makes Ed25519 reject the signature.
///
/// # Errors
///
/// - [`VerifyError::MalformedSignature`] — `signature.len() != 64`.
/// - [`VerifyError::Tampered`] — signature does not validate against
///   `public` over the canonical CBOR. Same variant covers all
///   "validation failed" cases (wrong key, flipped sig byte, mutated
///   link field) on purpose: the inbound posture is "reject" regardless
///   of *which* of those happened, and surfacing the distinction would
///   leak verification-side timing/structure to a misbehaving peer.
pub fn verify(
    public: &VerifyingKey,
    link: &SignableLink<'_>,
    signature: &[u8],
) -> Result<(), VerifyError> {
    if signature.len() != SIGNATURE_LEN {
        return Err(VerifyError::MalformedSignature);
    }
    let mut sig_arr = [0u8; SIGNATURE_LEN];
    sig_arr.copy_from_slice(signature);
    let sig = Signature::from_bytes(&sig_arr);

    // CBOR encode failures are surfaced as Tampered too — the only way
    // canonical_cbor errors today is a serialization bug, which from the
    // verifier's perspective is functionally equivalent to "we cannot
    // re-derive the bytes the peer signed, so we cannot trust this link".
    let payload = canonical_cbor(link).map_err(|_| VerifyError::Tampered)?;

    // #1808 — strict verification rejects malleable / non-canonical Ed25519
    // signatures, matching the v0.8.0 Pillar-1 verifiers + `forensic/bundle.rs`.
    public
        .verify_strict(&payload, &sig)
        .map_err(|_| VerifyError::Tampered)
}

// ---------------------------------------------------------------------------
// #626 Layer-3 (Task 1.3 / C4) — store-path write attestation
// ---------------------------------------------------------------------------
//
// The link verifier above reads the peer key from the on-disk key store
// (federation trust root). The WRITE attestation path is different: the
// trust anchor is the key BOUND into the agent's registration row by C3
// (`db::bind_agent_pubkey` / `MemoryStore::agent_pubkey`). A write that
// claims `agent_id` is upgraded from *claimed* to *attested* only when its
// Ed25519 signature verifies under that bound key.

/// Attestation level resolved for a store-path write.
///
/// Stamped into the stored row's metadata so downstream readers can tell a
/// bare `agent_id` claim apart from one cryptographically attested by a
/// holder of the agent's bound private key.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttestLevel {
    /// The write asserted an `agent_id` with no verified signature — a
    /// bare claim. The permissive default for unsigned writes (or writes
    /// whose agent has no bound key) when attestation is not required.
    Claimed,
    /// The write carried an Ed25519 signature that verified against the
    /// `agent_id`'s bound public key — the `agent_id` is attested.
    AgentAttested,
}

impl AttestLevel {
    /// Stable wire string for the `metadata.attest_level` field.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Claimed => "claimed",
            Self::AgentAttested => "agent_attested",
        }
    }

    /// Monotonic trust rank for the #1719 item-3a attested-identity LWW
    /// tiebreak: a strictly-higher rank wins a same-`updated_at` CRDT
    /// merge tiebreak ahead of the lexical-`id` fallback. `Claimed` (an
    /// unsigned bare claim) is the floor; `AgentAttested` (a verified
    /// signature against the bound key) outranks it.
    ///
    /// Resolved via an explicit method rather than a derived `Ord` so the
    /// wire enum stays non-comparable (the #970 enum-discipline convention
    /// `Tier` follows) — the rank is a merge-policy concern, not an
    /// intrinsic ordering of the variants.
    #[must_use]
    pub fn rank(self) -> u8 {
        match self {
            Self::Claimed => 0,
            Self::AgentAttested => 1,
        }
    }
}

/// Reason a store-path write was refused (or could not be attested) by
/// [`attest_write`].
#[derive(Debug, PartialEq, Eq)]
pub enum AttestError {
    /// A signature was presented and a bound key exists, but the signature
    /// did not verify — tampered payload, flipped signature byte, or a
    /// signature minted under a different key. ALWAYS a hard reject,
    /// regardless of the require-attestation posture: a presented-but-bad
    /// signature is never silently downgraded to a claim.
    Forged,
    /// Attestation is required (`AI_MEMORY_REQUIRE_AGENT_ATTESTATION`) but
    /// the write could not be attested — no signature was presented, or
    /// the agent has no bound key to verify against.
    AttestationRequired,
    /// The agent's bound public key could not be decoded — the
    /// registration metadata holds a corrupt `agent_pubkey`. Fail-closed
    /// (we cannot attest against a key we cannot parse).
    BadBoundKey,
    /// The presented signature blob was not exactly 64 bytes.
    MalformedSignature,
}

impl std::fmt::Display for AttestError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Forged => f.write_str(
                "write signature did not verify against the agent's bound public key — \
                 payload or signature bytes do not match what the agent signed",
            ),
            Self::AttestationRequired => f.write_str(
                "agent attestation is required but this write is unsigned or the agent \
                 has no bound public key",
            ),
            Self::BadBoundKey => f.write_str(
                "the agent's bound public key is malformed and cannot be used to verify",
            ),
            Self::MalformedSignature => f.write_str(
                "signature is not exactly 64 bytes — not a well-formed Ed25519 signature",
            ),
        }
    }
}

impl std::error::Error for AttestError {}

/// Verify `signature` over the canonical CBOR encoding of `write` using
/// `public`.
///
/// Mirror of [`verify`] for the store path: re-derives the exact bytes
/// [`crate::identity::sign::sign_write`] signed and checks the 64-byte
/// Ed25519 signature. Any divergence between the stored write fields and
/// what the agent signed makes the verify fail.
///
/// # Errors
///
/// - [`VerifyError::MalformedSignature`] — `signature.len() != 64`.
/// - [`VerifyError::Tampered`] — signature does not validate against
///   `public` over the canonical CBOR (wrong key, flipped byte, or mutated
///   write field).
pub fn verify_write(
    public: &VerifyingKey,
    write: &SignableWrite<'_>,
    signature: &[u8],
) -> Result<(), VerifyError> {
    if signature.len() != SIGNATURE_LEN {
        return Err(VerifyError::MalformedSignature);
    }
    let mut sig_arr = [0u8; SIGNATURE_LEN];
    sig_arr.copy_from_slice(signature);
    let sig = Signature::from_bytes(&sig_arr);

    let payload = canonical_cbor_write(write).map_err(|_| VerifyError::Tampered)?;
    // #1808 — strict verification (see `verify`); the #1464 content-attestation
    // lane must reject non-canonical signatures like the Pillar-1 verifiers.
    public
        .verify_strict(&payload, &sig)
        .map_err(|_| VerifyError::Tampered)
}

/// Resolve the [`AttestLevel`] for a store-path write — the C4 gate.
///
/// Decision table (`require` = `AI_MEMORY_REQUIRE_AGENT_ATTESTATION`):
///
/// | signature | bound key | verify | require=false | require=true |
/// |-----------|-----------|--------|---------------|--------------|
/// | present   | present   | ok     | `AgentAttested` | `AgentAttested` |
/// | present   | present   | fail   | `Err(Forged)`   | `Err(Forged)`   |
/// | present   | absent    | —      | `Claimed`       | `Err(Required)` |
/// | absent    | any       | —      | `Claimed`       | `Err(Required)` |
///
/// The load-bearing invariant: a *presented* signature that fails to
/// verify is ALWAYS rejected (`Forged`), never downgraded to `Claimed` —
/// so an attacker cannot strip attestation by sending a deliberately bad
/// signature. Only the *absence* of a signature (or of a bound key) maps
/// to the permissive `Claimed` posture, and only when `require` is unset.
///
/// # Errors
///
/// See the table — [`AttestError::Forged`], [`AttestError::AttestationRequired`],
/// [`AttestError::BadBoundKey`], or [`AttestError::MalformedSignature`].
pub fn attest_write(
    write: &SignableWrite<'_>,
    bound_pubkey_b64: Option<&str>,
    signature: Option<&[u8]>,
    require: bool,
) -> Result<AttestLevel, AttestError> {
    match (signature, bound_pubkey_b64) {
        (Some(sig), Some(pk_b64)) => {
            let public =
                keypair::decode_public_base64(pk_b64).map_err(|_| AttestError::BadBoundKey)?;
            verify_write(&public, write, sig).map_err(|e| match e {
                VerifyError::MalformedSignature => AttestError::MalformedSignature,
                // Tampered / wrong-key both collapse to Forged on the
                // write path — same fail-closed posture as the link verifier.
                VerifyError::Tampered | VerifyError::NoPublicKey => AttestError::Forged,
            })?;
            Ok(AttestLevel::AgentAttested)
        }
        // Either no signature, or a signature with no key to check it
        // against. Both are "cannot attest": permissive → Claimed,
        // strict → reject.
        _ => {
            if require {
                Err(AttestError::AttestationRequired)
            } else {
                Ok(AttestLevel::Claimed)
            }
        }
    }
}

// ---------------------------------------------------------------------------
// v0.8.0 Pillar-1 (#1709) — signed-signal verification
// ---------------------------------------------------------------------------

/// Verify `signature` over the canonical CBOR encoding of `signable` using the
/// raw 32-byte `sender_pubkey`.
///
/// Mirror of [`verify`] / [`verify_write`] for the signed-signal path:
/// rebuilds the [`VerifyingKey`] from the 32-byte `sender_pubkey`, re-derives
/// the exact bytes [`crate::identity::sign::sign_signal`] signed, and checks
/// the 64-byte Ed25519 signature. Any divergence between the persisted signal
/// fields and what the sender signed makes the verify fail.
///
/// Returns `false` — never panics — on ANY error: a `sender_pubkey` that is
/// not exactly 32 bytes or does not decode to a valid point, a `signature`
/// that is not exactly 64 bytes, a CBOR-encode failure, or a signature that
/// does not validate. The boolean contract matches the inbound posture
/// "either it verifies or we treat it as unsigned/untrusted" without leaking
/// which failure occurred.
#[must_use]
pub fn verify_signal(
    signable: &SignableSignal<'_>,
    signature: &[u8],
    sender_pubkey: &[u8],
) -> bool {
    let Ok(pk_arr): Result<[u8; ed25519_dalek::PUBLIC_KEY_LENGTH], _> = sender_pubkey.try_into()
    else {
        return false;
    };
    let Ok(public) = VerifyingKey::from_bytes(&pk_arr) else {
        return false;
    };
    let Ok(sig_arr): Result<[u8; SIGNATURE_LEN], _> = signature.try_into() else {
        return false;
    };
    let sig = Signature::from_bytes(&sig_arr);

    let Ok(payload) = canonical_cbor_signal(signable) else {
        return false;
    };
    public.verify_strict(&payload, &sig).is_ok()
}

/// Verify a federated action-state transition's Ed25519 signature (#1718 H2).
///
/// Mirror of [`verify_signal`]: rebuilds the [`VerifyingKey`] from the 32-byte
/// `signer_pubkey`, re-derives the exact bytes
/// [`crate::identity::sign::sign_transition`] signed, and checks the 64-byte
/// signature. Any divergence between the wire transition fields and what the
/// signer committed to makes the verify fail. The CALLER must additionally
/// bind `signer_pubkey` to an enrolled identity (the #1718 H2 "attested-not-
/// claimed" gate) — this function only proves the holder of `signer_pubkey`
/// signed exactly these transition fields.
///
/// Returns `false` — never panics — on ANY error: a `signer_pubkey` not
/// exactly 32 bytes or not a valid point, a `signature` not exactly 64 bytes,
/// a CBOR-encode failure, or a signature that does not validate.
#[must_use]
pub fn verify_transition(
    signable: &SignableTransition<'_>,
    signature: &[u8],
    signer_pubkey: &[u8],
) -> bool {
    let Ok(pk_arr): Result<[u8; ed25519_dalek::PUBLIC_KEY_LENGTH], _> = signer_pubkey.try_into()
    else {
        return false;
    };
    let Ok(public) = VerifyingKey::from_bytes(&pk_arr) else {
        return false;
    };
    let Ok(sig_arr): Result<[u8; SIGNATURE_LEN], _> = signature.try_into() else {
        return false;
    };
    let sig = Signature::from_bytes(&sig_arr);

    let Ok(payload) = canonical_cbor_transition(signable) else {
        return false;
    };
    public.verify_strict(&payload, &sig).is_ok()
}

// ---------------------------------------------------------------------------
// v0.8.0 Pillar-1 (#1709) — attested-checkpoint-resolution verification
// ---------------------------------------------------------------------------

/// Verify `signature` over the canonical CBOR encoding of `signable` using the
/// raw 32-byte `resolver_pubkey`.
///
/// Mirror of [`verify_signal`] for the attested-checkpoint-resolution path:
/// rebuilds the [`VerifyingKey`] from the 32-byte `resolver_pubkey`, re-derives
/// the exact bytes [`crate::identity::sign::sign_checkpoint_resolution`]
/// signed, and checks the 64-byte Ed25519 signature. Any divergence between
/// the persisted resolution fields and what the resolver signed makes the
/// verify fail — the separation-of-duties attestation cannot be replayed onto
/// a different checkpoint, resolver, verdict, or timestamp.
///
/// Returns `false` — never panics — on ANY error: a `resolver_pubkey` that is
/// not exactly 32 bytes or does not decode to a valid point, a `signature`
/// that is not exactly 64 bytes, a CBOR-encode failure, or a signature that
/// does not validate.
#[must_use]
pub fn verify_checkpoint_resolution(
    signable: &SignableCheckpointResolution<'_>,
    signature: &[u8],
    resolver_pubkey: &[u8],
) -> bool {
    let Ok(pk_arr): Result<[u8; ed25519_dalek::PUBLIC_KEY_LENGTH], _> = resolver_pubkey.try_into()
    else {
        return false;
    };
    let Ok(public) = VerifyingKey::from_bytes(&pk_arr) else {
        return false;
    };
    let Ok(sig_arr): Result<[u8; SIGNATURE_LEN], _> = signature.try_into() else {
        return false;
    };
    let sig = Signature::from_bytes(&sig_arr);

    let Ok(payload) = canonical_cbor_checkpoint_resolution(signable) else {
        return false;
    };
    public.verify_strict(&payload, &sig).is_ok()
}

/// Verify a routine's Ed25519 FREEZE-ATTESTATION signature against its raw
/// 32-byte `signer_pubkey`.
///
/// Mirror of [`verify_checkpoint_resolution`] for the frozen-routine path:
/// rebuilds the [`VerifyingKey`] from the 32-byte `signer_pubkey`, re-derives
/// the exact bytes [`crate::identity::sign::sign_routine_freeze`] signed, and
/// checks the 64-byte Ed25519 signature. Any divergence between the persisted
/// frozen fields and what the freezer signed makes the verify fail — the
/// regulatory-hold attestation cannot be replayed onto a different routine, a
/// tampered template, or a back-/forward-dated freeze.
///
/// Returns `false` — never panics — on ANY error: a `signer_pubkey` that is not
/// exactly 32 bytes or does not decode to a valid point, a `signature` that is
/// not exactly 64 bytes, a CBOR-encode failure, or a signature that does not
/// validate.
#[must_use]
pub fn verify_routine_freeze(
    signable: &SignableRoutineFreeze<'_>,
    signature: &[u8],
    signer_pubkey: &[u8],
) -> bool {
    let Ok(pk_arr): Result<[u8; ed25519_dalek::PUBLIC_KEY_LENGTH], _> = signer_pubkey.try_into()
    else {
        return false;
    };
    let Ok(public) = VerifyingKey::from_bytes(&pk_arr) else {
        return false;
    };
    let Ok(sig_arr): Result<[u8; SIGNATURE_LEN], _> = signature.try_into() else {
        return false;
    };
    let sig = Signature::from_bytes(&sig_arr);

    let Ok(payload) = canonical_cbor_routine_freeze(signable) else {
        return false;
    };
    public.verify_strict(&payload, &sig).is_ok()
}

/// Look up the public key associated with `observed_by` on this host's
/// on-disk key store.
///
/// Reuses the H1 [`keypair::load`] loader (same path layout: `<key_dir>/
/// <agent_id>.pub`). The loader will succeed for any `agent_id` whose
/// public-key file is present — it does not require the `.priv` file
/// (this host has no reason to hold a peer's private key, only the
/// matching public key it received via `identity import`).
///
/// Returns `None` when:
/// - `observed_by` is the empty string,
/// - the key directory cannot be resolved (extremely rare; only when the
///   OS does not advertise a config dir),
/// - no `<observed_by>.pub` file exists under the key directory,
/// - the on-disk file is malformed (length mismatch, etc.).
///
/// In every `None` case the caller should fall back to the
/// accept-and-flag-as-unsigned posture rather than rejecting the link.
#[must_use]
pub fn lookup_peer_public_key(observed_by: &str) -> Option<VerifyingKey> {
    if observed_by.is_empty() {
        return None;
    }
    let dir = keypair::default_key_dir().ok()?;
    lookup_peer_public_key_in(observed_by, &dir)
}

/// Variant of [`lookup_peer_public_key`] that takes an explicit key
/// directory. Used by tests so we can populate a tempdir with peer
/// public keys without touching the operator's real `~/.config/ai-memory`.
/// Callers in production code should prefer [`lookup_peer_public_key`]
/// so the storage location stays uniform across `keypair`, `sign`, and
/// `verify`.
#[must_use]
pub fn lookup_peer_public_key_in(observed_by: &str, dir: &Path) -> Option<VerifyingKey> {
    if observed_by.is_empty() {
        return None;
    }
    keypair::load(observed_by, dir).ok().map(|kp| kp.public)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::identity::keypair as kp_mod;
    use crate::identity::sign;
    use tempfile::TempDir;

    fn link_fixture() -> SignableLink<'static> {
        SignableLink {
            src_id: "src-001",
            dst_id: "dst-002",
            relation: "related_to",
            observed_by: Some("alice"),
            valid_from: Some("2026-05-05T00:00:00+00:00"),
            valid_until: None,
        }
    }

    #[test]
    fn verify_accepts_valid_signature() {
        // Happy path: alice signs, verifier holds alice.pub → accept.
        let alice = kp_mod::generate("alice").unwrap();
        let link = link_fixture();
        let sig = sign::sign(&alice, &link).unwrap();
        verify(&alice.public, &link, &sig).expect("happy-path verify must succeed");
    }

    #[test]
    fn verify_rejects_flipped_signature_byte() {
        // Single bit flip in the signature → Tampered. Ed25519 has no
        // malleability window — any altered byte invalidates.
        let alice = kp_mod::generate("alice").unwrap();
        let link = link_fixture();
        let mut sig = sign::sign(&alice, &link).unwrap();
        sig[0] ^= 0x01;
        let err = verify(&alice.public, &link, &sig).unwrap_err();
        assert_eq!(err, VerifyError::Tampered, "flipped sig byte must reject");
    }

    #[test]
    fn verify_rejects_mutated_link_content() {
        // Re-sign with relation=related_to, but verifier sees relation=
        // supersedes (same other fields). CBOR re-encoding produces a
        // different byte stream → Ed25519 rejects.
        let alice = kp_mod::generate("alice").unwrap();
        let original = link_fixture();
        let sig = sign::sign(&alice, &original).unwrap();

        let mut tampered = original.clone();
        tampered.relation = "supersedes";
        let err = verify(&alice.public, &tampered, &sig).unwrap_err();
        assert_eq!(
            err,
            VerifyError::Tampered,
            "mutated link content must reject"
        );
    }

    #[test]
    fn verify_rejects_wrong_pubkey() {
        // Signed by alice, attempted-verified with bob's pubkey →
        // Tampered. The variant deliberately doesn't distinguish "wrong
        // key" from "tampered content" so a misbehaving peer can't
        // probe which fields the verifier touched.
        let alice = kp_mod::generate("alice").unwrap();
        let bob = kp_mod::generate("bob").unwrap();
        let link = link_fixture();
        let sig = sign::sign(&alice, &link).unwrap();
        let err = verify(&bob.public, &link, &sig).unwrap_err();
        assert_eq!(err, VerifyError::Tampered);
    }

    #[test]
    fn verify_rejects_short_signature() {
        let alice = kp_mod::generate("alice").unwrap();
        let link = link_fixture();
        // 32 bytes is wrong (Ed25519 wants 64).
        let short = vec![0u8; 32];
        let err = verify(&alice.public, &link, &short).unwrap_err();
        assert_eq!(err, VerifyError::MalformedSignature);
    }

    #[test]
    fn verify_rejects_long_signature() {
        let alice = kp_mod::generate("alice").unwrap();
        let link = link_fixture();
        // 128 bytes is wrong (Ed25519 wants 64).
        let long = vec![0u8; 128];
        let err = verify(&alice.public, &link, &long).unwrap_err();
        assert_eq!(err, VerifyError::MalformedSignature);
    }

    #[test]
    fn verify_rejects_empty_signature() {
        let alice = kp_mod::generate("alice").unwrap();
        let link = link_fixture();
        let err = verify(&alice.public, &link, &[]).unwrap_err();
        assert_eq!(err, VerifyError::MalformedSignature);
    }

    #[test]
    fn lookup_peer_public_key_in_returns_none_for_unknown() {
        let dir = TempDir::new().unwrap();
        // Empty key dir → no enrolled peer.
        assert!(lookup_peer_public_key_in("alice", dir.path()).is_none());
    }

    #[test]
    fn lookup_peer_public_key_in_returns_none_for_empty_id() {
        let dir = TempDir::new().unwrap();
        assert!(lookup_peer_public_key_in("", dir.path()).is_none());
    }

    #[test]
    fn lookup_peer_public_key_in_finds_enrolled_pubkey() {
        // Mirror an `identity import` for a peer: write only the .pub
        // file under the key dir. lookup must return the same key.
        let dir = TempDir::new().unwrap();
        let alice = kp_mod::generate("alice").unwrap();
        let pub_only = kp_mod::AgentKeypair {
            agent_id: "alice".to_string(),
            public: alice.public,
            private: None,
        };
        kp_mod::save_public_only(&pub_only, dir.path()).unwrap();
        let found = lookup_peer_public_key_in("alice", dir.path()).expect("lookup hit");
        assert_eq!(found.to_bytes(), alice.public.to_bytes());
    }

    #[test]
    fn lookup_peer_public_key_in_finds_full_keypair_pub() {
        // A self-generated agent (with both .pub and .priv on disk) is
        // also a valid lookup target — useful in single-host loopback
        // tests where the same agent both signs and verifies.
        let dir = TempDir::new().unwrap();
        let alice = kp_mod::generate("alice").unwrap();
        kp_mod::save(&alice, dir.path()).unwrap();
        let found = lookup_peer_public_key_in("alice", dir.path()).expect("lookup hit");
        assert_eq!(found.to_bytes(), alice.public.to_bytes());
    }

    #[test]
    fn lookup_peer_public_key_in_skips_invalid_agent_id() {
        // `keypair::load` validates the agent_id; lookup should not
        // panic and should report `None` for invalid input.
        let dir = TempDir::new().unwrap();
        assert!(lookup_peer_public_key_in("has space", dir.path()).is_none());
        assert!(lookup_peer_public_key_in("has\0null", dir.path()).is_none());
    }

    #[test]
    fn end_to_end_peer_a_signs_peer_b_verifies() {
        // Two-host simulation: alice signs on host A; host B has only
        // alice.pub enrolled (no .priv). Host B looks up alice's pubkey
        // and verifies — passes.
        let host_b_keys = TempDir::new().unwrap();
        let alice = kp_mod::generate("alice").unwrap();

        // Host B operator imports alice's public key.
        let alice_pub_for_b = kp_mod::AgentKeypair {
            agent_id: "alice".to_string(),
            public: alice.public,
            private: None,
        };
        kp_mod::save_public_only(&alice_pub_for_b, host_b_keys.path()).unwrap();

        // Alice signs a link on host A.
        let link = link_fixture();
        let sig = sign::sign(&alice, &link).unwrap();

        // Host B receives the link, looks up alice's pubkey, verifies.
        let key_on_b =
            lookup_peer_public_key_in("alice", host_b_keys.path()).expect("alice enrolled on B");
        verify(&key_on_b, &link, &sig).expect("cross-host verify must succeed");
    }

    #[test]
    fn end_to_end_no_pubkey_returns_none_for_caller_to_handle() {
        // Host B has no key enrolled for alice → lookup returns None.
        // The caller (federation inbound) is responsible for the
        // accept-and-flag-as-unsigned posture; verify() is not invoked.
        let host_b_keys = TempDir::new().unwrap();
        assert!(lookup_peer_public_key_in("alice", host_b_keys.path()).is_none());
    }

    #[test]
    fn verify_error_display_messages_are_distinct() {
        // Sanity: each variant has a non-empty, distinct human message.
        let m_t = format!("{}", VerifyError::Tampered);
        let m_n = format!("{}", VerifyError::NoPublicKey);
        let m_m = format!("{}", VerifyError::MalformedSignature);
        assert!(!m_t.is_empty());
        assert!(!m_n.is_empty());
        assert!(!m_m.is_empty());
        assert_ne!(m_t, m_n);
        assert_ne!(m_n, m_m);
        assert_ne!(m_t, m_m);
    }

    // -----------------------------------------------------------------
    // #626 Layer-3 (Task 1.3 / C4) — verify_write + attest_write gate
    // -----------------------------------------------------------------

    fn body_hash(seed: u8) -> [u8; 32] {
        let mut h = [seed; 32];
        h[0] ^= 0x5A;
        h
    }

    fn write_fixture(body: &[u8; 32]) -> SignableWrite<'_> {
        SignableWrite {
            agent_id: "ai:curator",
            namespace: "team/alpha",
            title: "kubernetes deployment guide",
            kind: "fact",
            created_at: "2026-06-01T12:00:00+00:00",
            content_sha256: body,
        }
    }

    #[test]
    fn verify_write_accepts_valid_signature() {
        let kp = kp_mod::generate("ai:curator").unwrap();
        let body = body_hash(0x11);
        let write = write_fixture(&body);
        let sig = sign::sign_write(&kp, &write).unwrap();
        verify_write(&kp.public, &write, &sig).expect("happy-path write verify must succeed");
    }

    #[test]
    fn verify_write_rejects_flipped_signature_byte() {
        let kp = kp_mod::generate("ai:curator").unwrap();
        let body = body_hash(0x12);
        let write = write_fixture(&body);
        let mut sig = sign::sign_write(&kp, &write).unwrap();
        sig[0] ^= 0x01;
        assert_eq!(
            verify_write(&kp.public, &write, &sig).unwrap_err(),
            VerifyError::Tampered
        );
    }

    #[test]
    fn verify_write_rejects_mutated_payload() {
        let kp = kp_mod::generate("ai:curator").unwrap();
        let body = body_hash(0x13);
        let original = write_fixture(&body);
        let sig = sign::sign_write(&kp, &original).unwrap();
        let mut tampered = original.clone();
        tampered.agent_id = "ai:impostor";
        assert_eq!(
            verify_write(&kp.public, &tampered, &sig).unwrap_err(),
            VerifyError::Tampered
        );
    }

    #[test]
    fn verify_write_rejects_short_signature() {
        let kp = kp_mod::generate("ai:curator").unwrap();
        let body = body_hash(0x14);
        let write = write_fixture(&body);
        assert_eq!(
            verify_write(&kp.public, &write, &[0u8; 32]).unwrap_err(),
            VerifyError::MalformedSignature
        );
    }

    #[test]
    fn attest_write_signed_with_bound_key_is_attested() {
        let kp = kp_mod::generate("ai:curator").unwrap();
        let body = body_hash(0x21);
        let write = write_fixture(&body);
        let sig = sign::sign_write(&kp, &write).unwrap();
        let pk_b64 = kp.public_base64();
        // Permissive and strict both attest a valid signature.
        assert_eq!(
            attest_write(&write, Some(&pk_b64), Some(&sig), false).unwrap(),
            AttestLevel::AgentAttested
        );
        assert_eq!(
            attest_write(&write, Some(&pk_b64), Some(&sig), true).unwrap(),
            AttestLevel::AgentAttested
        );
    }

    #[test]
    fn attest_write_forged_signature_always_rejected() {
        let kp = kp_mod::generate("ai:curator").unwrap();
        let other = kp_mod::generate("ai:other").unwrap();
        let body = body_hash(0x22);
        let write = write_fixture(&body);
        // Sign with `other` but present `kp` as the bound key → forged.
        let sig = sign::sign_write(&other, &write).unwrap();
        let pk_b64 = kp.public_base64();
        // Forged rejects in BOTH postures — never downgraded to Claimed.
        assert_eq!(
            attest_write(&write, Some(&pk_b64), Some(&sig), false).unwrap_err(),
            AttestError::Forged
        );
        assert_eq!(
            attest_write(&write, Some(&pk_b64), Some(&sig), true).unwrap_err(),
            AttestError::Forged
        );
    }

    #[test]
    fn attest_write_unsigned_is_claimed_when_permissive_rejected_when_required() {
        let body = body_hash(0x23);
        let write = write_fixture(&body);
        let kp = kp_mod::generate("ai:curator").unwrap();
        let pk_b64 = kp.public_base64();
        // No signature → permissive Claimed.
        assert_eq!(
            attest_write(&write, Some(&pk_b64), None, false).unwrap(),
            AttestLevel::Claimed
        );
        // No signature, attestation required → reject.
        assert_eq!(
            attest_write(&write, Some(&pk_b64), None, true).unwrap_err(),
            AttestError::AttestationRequired
        );
    }

    #[test]
    fn attest_write_signature_without_bound_key_cannot_attest() {
        let kp = kp_mod::generate("ai:curator").unwrap();
        let body = body_hash(0x24);
        let write = write_fixture(&body);
        let sig = sign::sign_write(&kp, &write).unwrap();
        // Signature presented but agent has no bound key → cannot verify.
        // Permissive → Claimed; strict → reject.
        assert_eq!(
            attest_write(&write, None, Some(&sig), false).unwrap(),
            AttestLevel::Claimed
        );
        assert_eq!(
            attest_write(&write, None, Some(&sig), true).unwrap_err(),
            AttestError::AttestationRequired
        );
    }

    #[test]
    fn attest_write_malformed_signature_is_reported() {
        let kp = kp_mod::generate("ai:curator").unwrap();
        let body = body_hash(0x25);
        let write = write_fixture(&body);
        let pk_b64 = kp.public_base64();
        assert_eq!(
            attest_write(&write, Some(&pk_b64), Some(&[0u8; 10]), false).unwrap_err(),
            AttestError::MalformedSignature
        );
    }

    #[test]
    fn attest_write_bad_bound_key_fails_closed() {
        let kp = kp_mod::generate("ai:curator").unwrap();
        let body = body_hash(0x26);
        let write = write_fixture(&body);
        let sig = sign::sign_write(&kp, &write).unwrap();
        // Corrupt bound key (not decodable) → fail-closed.
        assert_eq!(
            attest_write(&write, Some("!!!not-base64!!!"), Some(&sig), false).unwrap_err(),
            AttestError::BadBoundKey
        );
    }

    #[test]
    fn attest_level_as_str_is_stable() {
        assert_eq!(AttestLevel::Claimed.as_str(), "claimed");
        assert_eq!(AttestLevel::AgentAttested.as_str(), "agent_attested");
    }

    // -----------------------------------------------------------------
    // v0.8.0 Pillar-1 (#1709) — verify_signal
    // -----------------------------------------------------------------

    fn signal_fixture(body: &[u8; 32]) -> SignableSignal<'_> {
        SignableSignal {
            id: "sig-001",
            namespace: "team/alpha",
            from_agent: "ai:curator",
            to_agent: Some("ai:planner"),
            subject: "deploy approval",
            body_sha256: body,
            signal_type: "request",
            in_reply_to: None,
            correlation_id: Some("corr-7"),
            created_at: 1_700_000_000,
        }
    }

    #[test]
    fn verify_signal_accepts_valid_signature() {
        let kp = kp_mod::generate("ai:curator").unwrap();
        let body = body_hash(0x31);
        let signal = signal_fixture(&body);
        let sig = sign::sign_signal(&kp, &signal).unwrap();
        assert!(
            verify_signal(&signal, &sig, &kp.public.to_bytes()),
            "happy-path signal verify must succeed"
        );
    }

    #[test]
    fn verify_signal_rejects_flipped_signature_byte() {
        let kp = kp_mod::generate("ai:curator").unwrap();
        let body = body_hash(0x32);
        let signal = signal_fixture(&body);
        let mut sig = sign::sign_signal(&kp, &signal).unwrap();
        sig[0] ^= 0x01;
        assert!(!verify_signal(&signal, &sig, &kp.public.to_bytes()));
    }

    #[test]
    fn verify_signal_rejects_mutated_payload() {
        let kp = kp_mod::generate("ai:curator").unwrap();
        let body = body_hash(0x33);
        let original = signal_fixture(&body);
        let sig = sign::sign_signal(&kp, &original).unwrap();
        let mut tampered = original.clone();
        tampered.subject = "deploy rejection";
        assert!(!verify_signal(&tampered, &sig, &kp.public.to_bytes()));
    }

    #[test]
    fn verify_signal_rejects_wrong_pubkey() {
        let alice = kp_mod::generate("alice").unwrap();
        let bob = kp_mod::generate("bob").unwrap();
        let body = body_hash(0x34);
        let signal = signal_fixture(&body);
        let sig = sign::sign_signal(&alice, &signal).unwrap();
        assert!(!verify_signal(&signal, &sig, &bob.public.to_bytes()));
    }

    #[test]
    fn verify_signal_rejects_malformed_signature_length() {
        let kp = kp_mod::generate("ai:curator").unwrap();
        let body = body_hash(0x35);
        let signal = signal_fixture(&body);
        // 32 bytes is wrong (Ed25519 wants 64).
        assert!(!verify_signal(&signal, &[0u8; 32], &kp.public.to_bytes()));
        // empty signature.
        assert!(!verify_signal(&signal, &[], &kp.public.to_bytes()));
    }

    #[test]
    fn verify_signal_rejects_malformed_pubkey_length() {
        let kp = kp_mod::generate("ai:curator").unwrap();
        let body = body_hash(0x36);
        let signal = signal_fixture(&body);
        let sig = sign::sign_signal(&kp, &signal).unwrap();
        // 16 bytes is wrong (Ed25519 pubkeys are 32 bytes).
        assert!(!verify_signal(&signal, &sig, &[0u8; 16]));
        // empty pubkey.
        assert!(!verify_signal(&signal, &sig, &[]));
    }

    // -----------------------------------------------------------------
    // v0.8.0 Pillar-1 (#1718) — verify_transition (action-state edges)
    // -----------------------------------------------------------------

    fn transition_fixture() -> SignableTransition<'static> {
        SignableTransition {
            action_id: "act-001",
            namespace: "team/alpha",
            from_state: "pending",
            to_state: "claimed",
            claimed_by: Some("ai:worker"),
            nonce: b"nonce-abc",
            created_at: 1_700_000_000,
        }
    }

    #[test]
    fn verify_transition_accepts_valid_signature() {
        let kp = kp_mod::generate("ai:worker").unwrap();
        let t = transition_fixture();
        let sig = sign::sign_transition(&kp, &t).unwrap();
        assert!(
            verify_transition(&t, &sig, &kp.public.to_bytes()),
            "happy-path transition verify must succeed"
        );
    }

    #[test]
    fn verify_transition_rejects_flipped_signature_byte() {
        let kp = kp_mod::generate("ai:worker").unwrap();
        let t = transition_fixture();
        let mut sig = sign::sign_transition(&kp, &t).unwrap();
        sig[0] ^= 0x01;
        assert!(!verify_transition(&t, &sig, &kp.public.to_bytes()));
    }

    #[test]
    fn verify_transition_rejects_mutated_edge() {
        // A signature minted for `pending -> claimed` must NOT verify for a
        // different target state (cross-edge replay guard, #1718 H1/H2).
        let kp = kp_mod::generate("ai:worker").unwrap();
        let original = transition_fixture();
        let sig = sign::sign_transition(&kp, &original).unwrap();
        let mut tampered = original.clone();
        tampered.to_state = "abandoned";
        assert!(!verify_transition(&tampered, &sig, &kp.public.to_bytes()));
    }

    #[test]
    fn verify_transition_rejects_replay_under_fresh_nonce() {
        // The nonce binds the signature to a single delivery — a captured
        // (bytes, sig) pair must NOT replay under a different nonce.
        let kp = kp_mod::generate("ai:worker").unwrap();
        let original = transition_fixture();
        let sig = sign::sign_transition(&kp, &original).unwrap();
        let mut replayed = original.clone();
        replayed.nonce = b"different-nonce";
        assert!(!verify_transition(&replayed, &sig, &kp.public.to_bytes()));
    }

    #[test]
    fn verify_transition_rejects_wrong_pubkey() {
        let alice = kp_mod::generate("alice").unwrap();
        let bob = kp_mod::generate("bob").unwrap();
        let t = transition_fixture();
        let sig = sign::sign_transition(&alice, &t).unwrap();
        assert!(!verify_transition(&t, &sig, &bob.public.to_bytes()));
    }

    #[test]
    fn verify_transition_rejects_malformed_lengths() {
        let kp = kp_mod::generate("ai:worker").unwrap();
        let t = transition_fixture();
        let sig = sign::sign_transition(&kp, &t).unwrap();
        assert!(!verify_transition(&t, &[0u8; 32], &kp.public.to_bytes()));
        assert!(!verify_transition(&t, &[], &kp.public.to_bytes()));
        assert!(!verify_transition(&t, &sig, &[0u8; 16]));
        assert!(!verify_transition(&t, &sig, &[]));
    }

    // -----------------------------------------------------------------
    // v0.8.0 Pillar-1 (#1709) — verify_checkpoint_resolution
    // -----------------------------------------------------------------

    fn resolution_fixture() -> SignableCheckpointResolution<'static> {
        SignableCheckpointResolution {
            checkpoint_id: "cp-001",
            namespace: "team/alpha",
            state: "resolved",
            resolved_by: "ai:approver",
            resolution: Some("approved"),
            resolved_at: 1_700_000_500,
        }
    }

    #[test]
    fn verify_checkpoint_resolution_accepts_valid_signature() {
        let kp = kp_mod::generate("ai:approver").unwrap();
        let r = resolution_fixture();
        let sig = sign::sign_checkpoint_resolution(&kp, &r).unwrap();
        assert!(
            verify_checkpoint_resolution(&r, &sig, &kp.public.to_bytes()),
            "happy-path checkpoint-resolution verify must succeed"
        );
    }

    #[test]
    fn verify_checkpoint_resolution_rejects_flipped_signature_byte() {
        let kp = kp_mod::generate("ai:approver").unwrap();
        let r = resolution_fixture();
        let mut sig = sign::sign_checkpoint_resolution(&kp, &r).unwrap();
        sig[0] ^= 0x01;
        assert!(!verify_checkpoint_resolution(
            &r,
            &sig,
            &kp.public.to_bytes()
        ));
    }

    #[test]
    fn verify_checkpoint_resolution_rejects_mutated_payload() {
        let kp = kp_mod::generate("ai:approver").unwrap();
        let original = resolution_fixture();
        let sig = sign::sign_checkpoint_resolution(&kp, &original).unwrap();
        // Flip the verdict after signing — must not verify.
        let mut tampered = original.clone();
        tampered.state = "rejected";
        assert!(!verify_checkpoint_resolution(
            &tampered,
            &sig,
            &kp.public.to_bytes()
        ));
    }

    #[test]
    fn verify_checkpoint_resolution_rejects_wrong_pubkey() {
        let alice = kp_mod::generate("alice").unwrap();
        let bob = kp_mod::generate("bob").unwrap();
        let r = resolution_fixture();
        let sig = sign::sign_checkpoint_resolution(&alice, &r).unwrap();
        assert!(!verify_checkpoint_resolution(
            &r,
            &sig,
            &bob.public.to_bytes()
        ));
    }

    #[test]
    fn verify_checkpoint_resolution_rejects_malformed_lengths() {
        let kp = kp_mod::generate("ai:approver").unwrap();
        let r = resolution_fixture();
        let sig = sign::sign_checkpoint_resolution(&kp, &r).unwrap();
        // 32-byte signature is wrong (Ed25519 wants 64); empty too.
        assert!(!verify_checkpoint_resolution(
            &r,
            &[0u8; 32],
            &kp.public.to_bytes()
        ));
        assert!(!verify_checkpoint_resolution(
            &r,
            &[],
            &kp.public.to_bytes()
        ));
        // 16-byte pubkey is wrong (Ed25519 pubkeys are 32 bytes); empty too.
        assert!(!verify_checkpoint_resolution(&r, &sig, &[0u8; 16]));
        assert!(!verify_checkpoint_resolution(&r, &sig, &[]));
    }

    // -----------------------------------------------------------------
    // v0.8.0 Pillar-1 (#1709) — verify_routine_freeze
    // -----------------------------------------------------------------

    fn routine_freeze_fixture<'a>(
        template: &'a [u8; 32],
        parameters: &'a [u8; 32],
    ) -> SignableRoutineFreeze<'a> {
        SignableRoutineFreeze {
            routine_id: "rt-001",
            namespace: "_rt",
            name: "deploy",
            template_sha256: template,
            parameters_sha256: parameters,
            frozen_at: 1_700_000_500,
        }
    }

    #[test]
    fn verify_routine_freeze_accepts_valid_signature() {
        let kp = kp_mod::generate("agent-author").unwrap();
        let template = body_hash(0x40);
        let parameters = body_hash(0x41);
        let r = routine_freeze_fixture(&template, &parameters);
        let sig = sign::sign_routine_freeze(&kp, &r).unwrap();
        assert!(
            verify_routine_freeze(&r, &sig, &kp.public.to_bytes()),
            "happy-path routine-freeze verify must succeed"
        );
    }

    #[test]
    fn verify_routine_freeze_rejects_flipped_signature_byte() {
        let kp = kp_mod::generate("agent-author").unwrap();
        let template = body_hash(0x42);
        let parameters = body_hash(0x43);
        let r = routine_freeze_fixture(&template, &parameters);
        let mut sig = sign::sign_routine_freeze(&kp, &r).unwrap();
        sig[0] ^= 0x01;
        assert!(!verify_routine_freeze(&r, &sig, &kp.public.to_bytes()));
    }

    #[test]
    fn verify_routine_freeze_rejects_mutated_template() {
        let kp = kp_mod::generate("agent-author").unwrap();
        let template = body_hash(0x44);
        let parameters = body_hash(0x45);
        let original = routine_freeze_fixture(&template, &parameters);
        let sig = sign::sign_routine_freeze(&kp, &original).unwrap();
        // Swap the template hash after signing — must not verify.
        let other_template = body_hash(0x99);
        let tampered = SignableRoutineFreeze {
            template_sha256: &other_template,
            ..original.clone()
        };
        assert!(!verify_routine_freeze(
            &tampered,
            &sig,
            &kp.public.to_bytes()
        ));
    }

    #[test]
    fn verify_routine_freeze_rejects_wrong_pubkey() {
        let alice = kp_mod::generate("alice").unwrap();
        let bob = kp_mod::generate("bob").unwrap();
        let template = body_hash(0x46);
        let parameters = body_hash(0x47);
        let r = routine_freeze_fixture(&template, &parameters);
        let sig = sign::sign_routine_freeze(&alice, &r).unwrap();
        assert!(!verify_routine_freeze(&r, &sig, &bob.public.to_bytes()));
    }

    #[test]
    fn verify_routine_freeze_rejects_malformed_lengths() {
        let kp = kp_mod::generate("agent-author").unwrap();
        let template = body_hash(0x48);
        let parameters = body_hash(0x49);
        let r = routine_freeze_fixture(&template, &parameters);
        let sig = sign::sign_routine_freeze(&kp, &r).unwrap();
        // 32-byte signature is wrong (Ed25519 wants 64); empty too.
        assert!(!verify_routine_freeze(
            &r,
            &[0u8; 32],
            &kp.public.to_bytes()
        ));
        assert!(!verify_routine_freeze(&r, &[], &kp.public.to_bytes()));
        // 16-byte pubkey is wrong (Ed25519 pubkeys are 32 bytes); empty too.
        assert!(!verify_routine_freeze(&r, &sig, &[0u8; 16]));
        assert!(!verify_routine_freeze(&r, &sig, &[]));
    }
}
