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

//! Outbound Ed25519 signing for `memory_links` (Track H, Task H2).
//!
//! Builds on H1 ([`crate::identity::keypair`]) — the per-agent
//! [`AgentKeypair`] is the signing key. This module provides the two
//! pieces H2 ships:
//!
//! 1. [`canonical_cbor`] — RFC 8949 §4.2.1 deterministic CBOR encoding
//!    of the six link fields the signature commits to:
//!    `src_id`, `dst_id`, `relation`, `observed_by`, `valid_from`,
//!    `valid_until`. Same bytes on every host, every architecture,
//!    every endianness — the precondition for round-tripping a
//!    signature through the federation wire.
//! 2. [`sign`] — wraps `canonical_cbor` + Ed25519 over the resulting
//!    bytes. Returns the 64-byte signature ready to drop into the
//!    `signature` BLOB column on `memory_links`.
//!
//! H3 will mirror [`canonical_cbor`] on the inbound path so verification
//! re-derives the same bytes from the inbound row before checking the
//! signature against the peer's public key.
//!
//! # Why CBOR?
//!
//! CBOR is the RustCrypto / IETF default for signed payloads (COSE
//! lives on top of CBOR). RFC 8949 §4.2.1 defines a *deterministic*
//! encoding: map keys sort lexicographically, integers use the smallest
//! length, no indefinite-length items, no semantic tags we don't need.
//! That gives us byte-stable input to Ed25519 without writing a custom
//! binary format and without depending on `serde_json`'s key-ordering
//! quirks (which are not part of its public contract).
//!
//! # Out of scope here
//!
//! - Inbound verification (H3).
//! - `attest_level` enum + `memory_verify` MCP tool (H4).
//! - `signed_events` audit table (H5).

use crate::models::field_names;
use anyhow::{Context, Result};
use ed25519_dalek::Signer;

use crate::identity::keypair::AgentKeypair;

/// The six fields the link signature commits to.
///
/// Decoupled from [`crate::models::MemoryLink`] on purpose: that struct
/// is the public wire shape for `get_links` (4 columns), while the
/// signed bundle includes the temporal-validity columns (`valid_from`,
/// `valid_until`, `observed_by`) added in v0.6.3 schema v15. Keeping
/// `SignableLink` separate means H3's verifier can deserialize directly
/// from a row without dragging the entire `MemoryLink` shape — and it
/// gives the canonical encoder a single, audited shape to commit to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignableLink<'a> {
    pub src_id: &'a str,
    pub dst_id: &'a str,
    pub relation: &'a str,
    /// Agent that observed / asserted this link. `None` when the link
    /// was created by an unidentified caller (rare on the signing path
    /// — the keypair's owner is normally the observer).
    pub observed_by: Option<&'a str>,
    /// RFC3339 instant the link became true. Always present on writes
    /// produced by `db::create_link` (set to "now" at insert time).
    pub valid_from: Option<&'a str>,
    /// RFC3339 instant the link was invalidated, or `None` if still
    /// valid. Almost always `None` at insert time; set later by
    /// `db::invalidate_link`.
    pub valid_until: Option<&'a str>,
}

/// RFC 8949 §4.2.1 deterministic CBOR encoding of the six signable
/// link fields.
///
/// The encoded shape is a CBOR map with 6 entries keyed by the field
/// names below. Map keys are emitted in sort order (per RFC 8949 §4.2.1
/// "Core Deterministic Encoding"), integers use the shortest form, and
/// `Option::None` is encoded as CBOR `null`. Encoding the same
/// `SignableLink` twice (or on a different host) produces identical
/// bytes — the precondition Ed25519 needs.
///
/// Field order matters at the *byte* level even though `ciborium`
/// canonicalises map keys for us — we still pass a deterministic
/// `Vec<(&str, ...)>` shape to keep this function's intent reviewable
/// without leaning on a non-obvious property of the encoder.
///
/// # Errors
///
/// Returns an error only when CBOR serialization fails — in practice
/// unreachable for the fixed-shape input above, but surfaced as a
/// `Result` so callers don't have to choose between panicking and
/// silently signing a truncated payload.
pub fn canonical_cbor(link: &SignableLink<'_>) -> Result<Vec<u8>> {
    // We model the payload as a plain `BTreeMap<&str, ciborium::Value>`
    // so map-key ordering is enforced at construction time — the encoder
    // walks the BTreeMap in iteration order, which matches lexicographic
    // sort. ciborium's `into_writer` emits canonical (smallest-int,
    // definite-length) representations by default.
    use std::collections::BTreeMap;
    let mut map: BTreeMap<&str, ciborium::Value> = BTreeMap::new();
    map.insert("src_id", ciborium::Value::Text(link.src_id.to_string()));
    map.insert("dst_id", ciborium::Value::Text(link.dst_id.to_string()));
    map.insert("relation", ciborium::Value::Text(link.relation.to_string()));
    map.insert(field_names::OBSERVED_BY, text_or_null(link.observed_by));
    map.insert(field_names::VALID_FROM, text_or_null(link.valid_from));
    map.insert(field_names::VALID_UNTIL, text_or_null(link.valid_until));

    // Convert the BTreeMap to a `ciborium::Value::Map` whose entries are
    // already in lexicographic key order. ciborium will preserve that
    // order on the wire — the documented default for `into_writer`.
    let entries: Vec<(ciborium::Value, ciborium::Value)> = map
        .into_iter()
        .map(|(k, v)| (ciborium::Value::Text(k.to_string()), v))
        .collect();
    let value = ciborium::Value::Map(entries);

    let mut out: Vec<u8> = Vec::with_capacity(128);
    ciborium::ser::into_writer(&value, &mut out).context("CBOR encode SignableLink")?;
    Ok(out)
}

/// Sign `link` with `keypair`'s private key.
///
/// Encodes the link via [`canonical_cbor`], then runs Ed25519 over the
/// resulting bytes. Returns the 64-byte signature, ready to drop into
/// the `signature` BLOB column on `memory_links`.
///
/// # Errors
///
/// - `keypair.private` is `None` (public-only handle — verification
///   only).
/// - The CBOR encoding step fails (in practice unreachable; surfaced
///   for completeness).
pub fn sign(keypair: &AgentKeypair, link: &SignableLink<'_>) -> Result<Vec<u8>> {
    let signing = keypair.private.as_ref().with_context(|| {
        format!(
            "AgentKeypair for {} has no private key — cannot sign",
            keypair.agent_id
        )
    })?;
    let bytes = canonical_cbor(link)?;
    let sig = signing.sign(&bytes);
    Ok(sig.to_bytes().to_vec())
}

/// Helper: lift `Option<&str>` into a CBOR `Text` or `Null`. Encoding
/// `None` as `null` (rather than dropping the key) keeps the map's key
/// set fixed across rows — H3's verifier can re-derive the bytes
/// without branching on which optional fields were present.
fn text_or_null(opt: Option<&str>) -> ciborium::Value {
    match opt {
        Some(s) => ciborium::Value::Text(s.to_string()),
        None => ciborium::Value::Null,
    }
}

// ---------------------------------------------------------------------------
// v0.7.0 issue #812 / #813 — SignablePersona + sign_persona
// ---------------------------------------------------------------------------
//
// Mirrors the `SignableLink` shape: a single, audited surface for the
// seven fields the persona signature commits to, encoded via RFC 8949
// §4.2.1 deterministic CBOR. The body of the persona Markdown is
// hashed (SHA-256) BEFORE entering the signed envelope so the payload
// stays bounded (32 bytes) regardless of body length — Ed25519 over
// kilobytes of prose would still work, but the bounded shape lets the
// `signed_events` row carry the same `payload_hash` cheaply.

/// The seven fields the persona signature commits to.
///
/// `body_md_sha256` is the SHA-256 of the UTF-8 bytes of the rendered
/// persona Markdown body (the same string that lands in
/// `memories.content`). Hashing it before signing keeps the canonical
/// payload bounded at ~200 bytes regardless of body length — a 300-500
/// word persona body would otherwise dominate the signed envelope and
/// inflate every `signed_events.payload_hash` recomputation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignablePersona<'a> {
    /// The Persona memory's id (UUIDv4). Stable per (entity_id,
    /// namespace, version) tuple — `PersonaGenerator::generate` mints
    /// it before computing the signature.
    pub persona_id: &'a str,
    /// Subject the persona distils. Mirrors `Persona::entity_id`.
    pub entity_id: &'a str,
    /// Namespace the persona was minted under.
    pub namespace: &'a str,
    /// Monotonic version counter — `1` on the first generation, then
    /// `prev + 1` per regeneration. Pinned in the signature so a
    /// regeneration cannot replay an earlier version's signed bytes.
    pub version: i32,
    /// RFC3339 generation timestamp pinned in `metadata.persona.generated_at`.
    pub generated_at: &'a str,
    /// Source reflection ids — one `derives_from` edge per element.
    /// Order matters at the byte level (the CBOR encoder preserves the
    /// slice order); the writer pins the order to match
    /// `metadata.persona.sources`.
    pub sources: &'a [String],
    /// SHA-256 (32 bytes) over the rendered persona Markdown body's
    /// UTF-8 bytes. Bounds the signed payload size.
    pub body_md_sha256: &'a [u8; 32],
}

/// RFC 8949 §4.2.1 deterministic CBOR encoding of the seven signable
/// persona fields.
///
/// The encoded shape is a CBOR map with seven entries keyed by the
/// field names below. Map keys are emitted in sort order (per RFC 8949
/// §4.2.1 "Core Deterministic Encoding"), integers use the shortest
/// form, the body hash is encoded as CBOR `bytes`, and the source-id
/// list is encoded as an ordered CBOR array (slice order preserved).
/// Encoding the same `SignablePersona` twice (or on a different host)
/// produces identical bytes — the precondition Ed25519 needs.
///
/// # Errors
///
/// Returns an error only when CBOR serialization fails — in practice
/// unreachable for the fixed-shape input above, but surfaced as a
/// `Result` so callers don't have to choose between panicking and
/// silently signing a truncated payload.
pub fn canonical_cbor_persona(p: &SignablePersona<'_>) -> Result<Vec<u8>> {
    use std::collections::BTreeMap;
    let mut map: BTreeMap<&str, ciborium::Value> = BTreeMap::new();
    map.insert(
        "persona_id",
        ciborium::Value::Text(p.persona_id.to_string()),
    );
    map.insert("entity_id", ciborium::Value::Text(p.entity_id.to_string()));
    map.insert("namespace", ciborium::Value::Text(p.namespace.to_string()));
    map.insert(
        "version",
        ciborium::Value::Integer(ciborium::value::Integer::from(p.version)),
    );
    map.insert(
        field_names::GENERATED_AT,
        ciborium::Value::Text(p.generated_at.to_string()),
    );
    let sources_val = ciborium::Value::Array(
        p.sources
            .iter()
            .map(|s| ciborium::Value::Text(s.clone()))
            .collect(),
    );
    map.insert("sources", sources_val);
    map.insert(
        "body_md_sha256",
        ciborium::Value::Bytes(p.body_md_sha256.to_vec()),
    );

    let entries: Vec<(ciborium::Value, ciborium::Value)> = map
        .into_iter()
        .map(|(k, v)| (ciborium::Value::Text(k.to_string()), v))
        .collect();
    let value = ciborium::Value::Map(entries);

    let mut out: Vec<u8> = Vec::with_capacity(256);
    ciborium::ser::into_writer(&value, &mut out).context("CBOR encode SignablePersona")?;
    Ok(out)
}

/// Sign `persona` with `keypair`'s private key.
///
/// Encodes the persona via [`canonical_cbor_persona`], then runs
/// Ed25519 over the resulting bytes. Returns the 64-byte signature,
/// ready to drop into the `metadata.persona.signature` base64 field on
/// the persona memory and into the `signature` BLOB column on the
/// corresponding `signed_events` row.
///
/// # Errors
///
/// - `keypair.private` is `None` (public-only handle — verification
///   only).
/// - The CBOR encoding step fails (in practice unreachable; surfaced
///   for completeness).
pub fn sign_persona(keypair: &AgentKeypair, persona: &SignablePersona<'_>) -> Result<Vec<u8>> {
    let signing = keypair.private.as_ref().with_context(|| {
        format!(
            "AgentKeypair for {} has no private key — cannot sign persona",
            keypair.agent_id
        )
    })?;
    let bytes = canonical_cbor_persona(persona)?;
    let sig = signing.sign(&bytes);
    Ok(sig.to_bytes().to_vec())
}

// ---------------------------------------------------------------------------
// v0.7.0 #626 Layer-3 (Task 1.3) — SignableWrite + sign_write
// ---------------------------------------------------------------------------
//
// Closes the claimed→attested agent_id gap on the *store* path. A bare
// `store` request asserts `agent_id` as a free-text claim — anyone can
// type any id. Layer-3 lets a holder of the agent's private key sign the
// write so the verifier can re-derive these bytes from the stored row and
// confirm the `agent_id` was *attested* (the signer held the key bound to
// that id), not merely claimed.
//
// Mirrors `SignableLink` / `SignablePersona`: a single audited surface for
// the six fields the write signature commits to, encoded via RFC 8949
// §4.2.1 deterministic CBOR. The memory body is hashed (SHA-256) BEFORE
// entering the envelope so the signed payload stays bounded (~200 bytes)
// regardless of content length — the same bound `SignablePersona` uses.

/// The six fields the store-path write signature commits to.
///
/// Decoupled from [`crate::models::Memory`] on purpose: the signed bundle
/// pins exactly the identity-bearing surface of a write (who, where, what
/// title, what content, what kind, when) without dragging the full
/// `Memory` shape — so the verifier can re-derive the bytes directly from
/// the persisted row, and the canonical encoder has a single, audited
/// shape to commit to.
///
/// `content_sha256` is the SHA-256 of the UTF-8 bytes of the memory
/// content (the same string that lands in `memories.content`). Hashing it
/// before signing keeps the canonical payload bounded regardless of body
/// length — a multi-kilobyte memory would otherwise dominate the signed
/// envelope and inflate every `signed_events.payload_hash` recomputation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignableWrite<'a> {
    /// The claiming agent's id. This is the field the attestation gate
    /// exists to bind: the signature proves the signer held the keypair
    /// registered to this id, upgrading the write from *claimed* to
    /// *attested*.
    pub agent_id: &'a str,
    /// Namespace the write targets.
    pub namespace: &'a str,
    /// Memory title (the `(title, namespace)` pair is the upsert key, so
    /// it is identity-bearing and must be inside the signed surface).
    pub title: &'a str,
    /// Memory kind discriminant (e.g. `"fact"`, `"plan"`). Pinned so a
    /// signature minted for one kind cannot be replayed onto another.
    pub kind: &'a str,
    /// RFC3339 creation timestamp pinned at insert time. Inside the
    /// signed surface so a captured signature cannot be replayed to
    /// back- or forward-date a write.
    pub created_at: &'a str,
    /// SHA-256 (32 bytes) over the rendered memory content's UTF-8 bytes.
    /// Bounds the signed payload size.
    pub content_sha256: &'a [u8; 32],
}

/// RFC 8949 §4.2.1 deterministic CBOR encoding of the six signable write
/// fields.
///
/// The encoded shape is a CBOR map with six entries keyed by the field
/// names below. Map keys are emitted in sort order (per RFC 8949 §4.2.1
/// "Core Deterministic Encoding"), the content hash is encoded as CBOR
/// `bytes`, and all other fields as CBOR `text`. Encoding the same
/// `SignableWrite` twice (or on a different host) produces identical
/// bytes — the precondition Ed25519 needs.
///
/// # Errors
///
/// Returns an error only when CBOR serialization fails — in practice
/// unreachable for the fixed-shape input above, but surfaced as a
/// `Result` so callers don't have to choose between panicking and
/// silently signing a truncated payload.
pub fn canonical_cbor_write(w: &SignableWrite<'_>) -> Result<Vec<u8>> {
    use std::collections::BTreeMap;
    let mut map: BTreeMap<&str, ciborium::Value> = BTreeMap::new();
    map.insert("agent_id", ciborium::Value::Text(w.agent_id.to_string()));
    map.insert("namespace", ciborium::Value::Text(w.namespace.to_string()));
    map.insert("title", ciborium::Value::Text(w.title.to_string()));
    map.insert("kind", ciborium::Value::Text(w.kind.to_string()));
    map.insert(
        field_names::CREATED_AT,
        ciborium::Value::Text(w.created_at.to_string()),
    );
    map.insert(
        field_names::CONTENT_SHA256,
        ciborium::Value::Bytes(w.content_sha256.to_vec()),
    );

    let entries: Vec<(ciborium::Value, ciborium::Value)> = map
        .into_iter()
        .map(|(k, v)| (ciborium::Value::Text(k.to_string()), v))
        .collect();
    let value = ciborium::Value::Map(entries);

    let mut out: Vec<u8> = Vec::with_capacity(256);
    ciborium::ser::into_writer(&value, &mut out).context("CBOR encode SignableWrite")?;
    Ok(out)
}

/// Sign `write` with `keypair`'s private key.
///
/// Encodes the write via [`canonical_cbor_write`], then runs Ed25519 over
/// the resulting bytes. Returns the 64-byte signature, ready to drop into
/// the store-path signature wire field and the `signed_events` row.
///
/// # Errors
///
/// - `keypair.private` is `None` (public-only handle — verification
///   only).
/// - The CBOR encoding step fails (in practice unreachable; surfaced for
///   completeness).
pub fn sign_write(keypair: &AgentKeypair, write: &SignableWrite<'_>) -> Result<Vec<u8>> {
    let signing = keypair.private.as_ref().with_context(|| {
        format!(
            "AgentKeypair for {} has no private key — cannot sign write",
            keypair.agent_id
        )
    })?;
    let bytes = canonical_cbor_write(write)?;
    let sig = signing.sign(&bytes);
    Ok(sig.to_bytes().to_vec())
}

// ---------------------------------------------------------------------------
// v0.8.0 Pillar-1 (#1709) — SignableSignal + sign_signal
// ---------------------------------------------------------------------------
//
// Mirrors the `SignableLink` / `SignablePersona` / `SignableWrite` shapes: a
// single, audited surface for the IMMUTABLE fields a signal signature commits
// to, encoded via RFC 8949 §4.2.1 deterministic CBOR. The mutable lifecycle
// columns (`delivered_at`, `read_at`, `acknowledged_at`, `expires_at`) are
// deliberately EXCLUDED — they are stamped after the signal is sent, so
// committing to them would invalidate the signature on first delivery. The
// JSON body is hashed (SHA-256) BEFORE entering the envelope so the signed
// payload stays bounded (32 bytes) regardless of body length — the same bound
// `SignablePersona` / `SignableWrite` use for `body_md_sha256` /
// `content_sha256`.

/// The immutable fields a signal signature commits to.
///
/// Decoupled from [`crate::models::Signal`] on purpose: the signed bundle pins
/// exactly the identity-bearing surface of a signal (who, where, what subject,
/// what payload, what type, what threading, when) WITHOUT the mutable delivery
/// lifecycle columns — so the verifier can re-derive the bytes directly from a
/// persisted row at any lifecycle stage, and the canonical encoder has a
/// single audited shape to commit to.
///
/// `body_sha256` is the SHA-256 of the UTF-8 bytes of the signal body's
/// canonical JSON string (`Signal::body.to_string()`). Hashing it before
/// signing keeps the canonical payload bounded regardless of body length — a
/// multi-kilobyte JSON payload would otherwise dominate the signed envelope.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignableSignal<'a> {
    /// The signal's id (UUIDv4). Pinned so a signature minted for one signal
    /// cannot be replayed onto another.
    pub id: &'a str,
    /// Namespace the signal was sent within.
    pub namespace: &'a str,
    /// Sending agent. The identity-bearing field the signature attests: the
    /// signer held the keypair bound to this `from_agent`.
    pub from_agent: &'a str,
    /// Recipient agent, or `None` for a namespace broadcast.
    pub to_agent: Option<&'a str>,
    /// Free-text subject line.
    pub subject: &'a str,
    /// SHA-256 (32 bytes) over the signal body's canonical JSON string's
    /// UTF-8 bytes. Bounds the signed payload size.
    pub body_sha256: &'a [u8; 32],
    /// Canonical signal-type spelling (`SignalType::as_str`). Pinned so a
    /// signature minted for one type cannot be replayed onto another.
    pub signal_type: &'a str,
    /// Threads a `response` back onto its `request`, or `None`.
    pub in_reply_to: Option<&'a str>,
    /// Groups a signal into a conversation thread, or `None`.
    pub correlation_id: Option<&'a str>,
    /// Epoch-seconds creation timestamp. Inside the signed surface so a
    /// captured signature cannot be replayed to back- or forward-date a
    /// signal.
    pub created_at: i64,
}

/// RFC 8949 §4.2.1 deterministic CBOR encoding of the signable signal fields.
///
/// The encoded shape is a CBOR map keyed by the field names below. Map keys
/// are emitted in sort order (per RFC 8949 §4.2.1 "Core Deterministic
/// Encoding"), `Option::None` is encoded as CBOR `null`, `created_at` as a
/// CBOR integer, and the body hash as CBOR `bytes`. Encoding the same
/// `SignableSignal` twice (or on a different host) produces identical bytes —
/// the precondition Ed25519 needs.
///
/// # Errors
///
/// Returns an error only when CBOR serialization fails — in practice
/// unreachable for the fixed-shape input above, but surfaced as a `Result` so
/// callers don't have to choose between panicking and silently signing a
/// truncated payload.
pub fn canonical_cbor_signal(s: &SignableSignal<'_>) -> Result<Vec<u8>> {
    use std::collections::BTreeMap;
    let mut map: BTreeMap<&str, ciborium::Value> = BTreeMap::new();
    map.insert("id", ciborium::Value::Text(s.id.to_string()));
    map.insert("namespace", ciborium::Value::Text(s.namespace.to_string()));
    map.insert(
        "from_agent",
        ciborium::Value::Text(s.from_agent.to_string()),
    );
    map.insert("to_agent", text_or_null(s.to_agent));
    map.insert("subject", ciborium::Value::Text(s.subject.to_string()));
    map.insert(
        "body_sha256",
        ciborium::Value::Bytes(s.body_sha256.to_vec()),
    );
    map.insert(
        "signal_type",
        ciborium::Value::Text(s.signal_type.to_string()),
    );
    map.insert("in_reply_to", text_or_null(s.in_reply_to));
    map.insert(field_names::CORRELATION_ID, text_or_null(s.correlation_id));
    map.insert(
        field_names::CREATED_AT,
        ciborium::Value::Integer(ciborium::value::Integer::from(s.created_at)),
    );

    let entries: Vec<(ciborium::Value, ciborium::Value)> = map
        .into_iter()
        .map(|(k, v)| (ciborium::Value::Text(k.to_string()), v))
        .collect();
    let value = ciborium::Value::Map(entries);

    let mut out: Vec<u8> = Vec::with_capacity(256);
    ciborium::ser::into_writer(&value, &mut out).context("CBOR encode SignableSignal")?;
    Ok(out)
}

/// Sign `signal` with `keypair`'s private key.
///
/// Encodes the signal via [`canonical_cbor_signal`], then runs Ed25519 over the
/// resulting bytes. Returns the 64-byte signature, ready to drop into the
/// `signature` BLOB column on the `signals` table.
///
/// # Errors
///
/// - `keypair.private` is `None` (public-only handle — verification only).
/// - The CBOR encoding step fails (in practice unreachable; surfaced for
///   completeness).
pub fn sign_signal(keypair: &AgentKeypair, signal: &SignableSignal<'_>) -> Result<Vec<u8>> {
    let signing = keypair.private.as_ref().with_context(|| {
        format!(
            "AgentKeypair for {} has no private key — cannot sign signal",
            keypair.agent_id
        )
    })?;
    let bytes = canonical_cbor_signal(signal)?;
    let sig = signing.sign(&bytes);
    Ok(sig.to_bytes().to_vec())
}

// ---------------------------------------------------------------------------
// v0.8.0 Pillar-1 (#1718) — SignableTransition + sign_transition
// (end-to-end author attestation for FEDERATED action-state transitions)
// ---------------------------------------------------------------------------

/// The fields a federated action-state TRANSITION signature commits to — the
/// end-to-end author attestation answering "which attested actor drove THIS
/// action from `from_state` to `to_state`, with what nonce, when" (#1718 H2).
///
/// Decoupled from [`crate::models::Action`] on purpose (same rationale as
/// [`SignableSignal`] / [`SignableCheckpointResolution`]): the full `Action`
/// carries mutable columns (`title`, `payload`, `priority`, `metadata`,
/// `vector_clock`) the transition attestation deliberately does NOT bind. The
/// signature commits to the immutable transition surface only, so a captured
/// signature cannot be replayed onto a different action, edge, actor, or
/// timestamp, and the receiver can re-derive the bytes from the wire op
/// without the full `Action` shape. `nonce` binds the signature to a single
/// delivery so a captured `(bytes, sig)` pair cannot be replayed under a fresh
/// nonce without the private key.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignableTransition<'a> {
    /// The action's id (UUIDv4). Pinned so a signature minted for one action
    /// cannot be replayed onto another.
    pub action_id: &'a str,
    /// Namespace the action lives in.
    pub namespace: &'a str,
    /// Expected current state (canonical `ActionState::as_str`). Pinned so a
    /// signature for one edge (e.g. `pending -> claimed`) cannot be replayed as
    /// a different edge.
    pub from_state: &'a str,
    /// Target state (canonical `ActionState::as_str`).
    pub to_state: &'a str,
    /// The attested actor the transition claims (e.g. the lease holder), or
    /// `None`. The receiver persists THIS attested value, never a peer-supplied
    /// claimed field.
    pub claimed_by: Option<&'a str>,
    /// Per-delivery anti-replay nonce.
    pub nonce: &'a [u8],
    /// Epoch seconds of the transition.
    pub created_at: i64,
}

/// Canonical CBOR bytes for a [`SignableTransition`]. Field order is
/// normalised by the `BTreeMap` key-sort at encode time (same determinism
/// contract as [`canonical_cbor_signal`]), so producer and verifier commit to
/// byte-identical bytes regardless of struct-literal field order.
///
/// # Errors
/// Returns the `ciborium` encode error on a pathological serialization
/// failure.
pub fn canonical_cbor_transition(t: &SignableTransition<'_>) -> Result<Vec<u8>> {
    use std::collections::BTreeMap;
    let mut map: BTreeMap<&str, ciborium::Value> = BTreeMap::new();
    map.insert("action_id", ciborium::Value::Text(t.action_id.to_string()));
    map.insert("namespace", ciborium::Value::Text(t.namespace.to_string()));
    map.insert(
        "from_state",
        ciborium::Value::Text(t.from_state.to_string()),
    );
    map.insert("to_state", ciborium::Value::Text(t.to_state.to_string()));
    map.insert("claimed_by", text_or_null(t.claimed_by));
    map.insert("nonce", ciborium::Value::Bytes(t.nonce.to_vec()));
    map.insert(
        field_names::CREATED_AT,
        ciborium::Value::Integer(ciborium::value::Integer::from(t.created_at)),
    );

    let entries: Vec<(ciborium::Value, ciborium::Value)> = map
        .into_iter()
        .map(|(k, v)| (ciborium::Value::Text(k.to_string()), v))
        .collect();
    let value = ciborium::Value::Map(entries);

    let mut out: Vec<u8> = Vec::with_capacity(256);
    ciborium::ser::into_writer(&value, &mut out).context("CBOR encode SignableTransition")?;
    Ok(out)
}

/// Sign a [`SignableTransition`] with `keypair`'s private key, returning the
/// 64-byte Ed25519 signature. Verified inbound by
/// [`crate::identity::verify::verify_transition`].
///
/// # Errors
/// Returns an error when `keypair` is public-only (`can_sign() == false`) or
/// the CBOR encode fails.
pub fn sign_transition(
    keypair: &AgentKeypair,
    transition: &SignableTransition<'_>,
) -> Result<Vec<u8>> {
    let signing = keypair.private.as_ref().with_context(|| {
        format!(
            "AgentKeypair for {} has no private key — cannot sign transition",
            keypair.agent_id
        )
    })?;
    let bytes = canonical_cbor_transition(transition)?;
    let sig = signing.sign(&bytes);
    Ok(sig.to_bytes().to_vec())
}

// ---------------------------------------------------------------------------
// v0.8.0 Pillar-1 (#1709) — SignableCheckpointResolution + sign_checkpoint_resolution
// ---------------------------------------------------------------------------

/// The fields a checkpoint *resolution* signature commits to — the
/// separation-of-duties attestation answering "who resolved this checkpoint,
/// to what, when".
///
/// Decoupled from [`crate::models::Checkpoint`] on purpose (same rationale as
/// [`SignableSignal`] / [`SignableLink`]): the full `Checkpoint` carries
/// mutable-before-resolution columns (`condition`, `metadata`, `deadline_at`,
/// …) that the resolution attestation deliberately does NOT bind. The
/// signature commits to the *immutable-once-resolved* surface only, so a
/// verifier can re-derive the bytes from a resolved row without dragging the
/// entire `Checkpoint` shape, and a captured signature cannot be replayed onto
/// a different checkpoint, resolver, verdict, or timestamp.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignableCheckpointResolution<'a> {
    /// The checkpoint's id (UUIDv4). Pinned so a signature minted for one
    /// checkpoint cannot be replayed onto another.
    pub checkpoint_id: &'a str,
    /// Namespace the checkpoint was created within.
    pub namespace: &'a str,
    /// The resolved lifecycle state spelling (`CheckpointState::as_str` — e.g.
    /// `"resolved"` / `"rejected"`). Pinned so a signature minted for one
    /// verdict cannot be replayed onto another.
    pub state: &'a str,
    /// The resolving agent — the identity-bearing field the signature attests:
    /// the signer held the keypair bound to this `resolved_by`.
    pub resolved_by: &'a str,
    /// Free-text resolution verdict, or `None`.
    pub resolution: Option<&'a str>,
    /// Epoch-seconds resolution timestamp. Inside the signed surface so a
    /// captured signature cannot be replayed to back- or forward-date a
    /// resolution.
    pub resolved_at: i64,
}

/// RFC 8949 §4.2.1 deterministic CBOR encoding of the signable
/// checkpoint-resolution fields.
///
/// Same construction discipline as [`canonical_cbor_signal`]: a
/// `BTreeMap<&str, ciborium::Value>` so map-key order is enforced at build
/// time, `Option` lifted via [`text_or_null`] so the key set is fixed across
/// rows, and `resolved_at` encoded as a CBOR `Integer`.
///
/// # Errors
/// Returns an error only when CBOR serialization fails — in practice
/// unreachable for the fixed-shape input above, surfaced as a `Result` so
/// callers don't have to choose between panicking and silently signing a
/// truncated payload.
pub fn canonical_cbor_checkpoint_resolution(
    r: &SignableCheckpointResolution<'_>,
) -> Result<Vec<u8>> {
    use std::collections::BTreeMap;
    let mut map: BTreeMap<&str, ciborium::Value> = BTreeMap::new();
    map.insert(
        "checkpoint_id",
        ciborium::Value::Text(r.checkpoint_id.to_string()),
    );
    map.insert("namespace", ciborium::Value::Text(r.namespace.to_string()));
    map.insert("state", ciborium::Value::Text(r.state.to_string()));
    map.insert(
        "resolved_by",
        ciborium::Value::Text(r.resolved_by.to_string()),
    );
    map.insert("resolution", text_or_null(r.resolution));
    map.insert(
        "resolved_at",
        ciborium::Value::Integer(ciborium::value::Integer::from(r.resolved_at)),
    );

    let entries: Vec<(ciborium::Value, ciborium::Value)> = map
        .into_iter()
        .map(|(k, v)| (ciborium::Value::Text(k.to_string()), v))
        .collect();
    let value = ciborium::Value::Map(entries);

    let mut out: Vec<u8> = Vec::with_capacity(256);
    ciborium::ser::into_writer(&value, &mut out)
        .context("CBOR encode SignableCheckpointResolution")?;
    Ok(out)
}

/// Sign a checkpoint resolution with `keypair`'s private key.
///
/// Encodes the resolution via [`canonical_cbor_checkpoint_resolution`], then
/// runs Ed25519 over the resulting bytes. Returns the 64-byte signature, ready
/// to drop into the `signature` BLOB column on `checkpoints`.
///
/// # Errors
/// - `keypair.private` is `None` (public-only handle — verification only).
/// - The CBOR encoding step fails (in practice unreachable; surfaced for
///   completeness).
pub fn sign_checkpoint_resolution(
    keypair: &AgentKeypair,
    resolution: &SignableCheckpointResolution<'_>,
) -> Result<Vec<u8>> {
    let signing = keypair.private.as_ref().with_context(|| {
        format!(
            "AgentKeypair for {} has no private key — cannot sign checkpoint resolution",
            keypair.agent_id
        )
    })?;
    let bytes = canonical_cbor_checkpoint_resolution(resolution)?;
    let sig = signing.sign(&bytes);
    Ok(sig.to_bytes().to_vec())
}

// ---------------------------------------------------------------------------
// v0.8.0 Pillar-1 (#1709) — SignableRoutineFreeze + sign_routine_freeze
// ---------------------------------------------------------------------------

/// The fields a routine *freeze* signature commits to — the regulatory-hold
/// FREEZE-ATTESTATION answering "this exact immutable template was frozen by X
/// at T".
///
/// Decoupled from [`crate::models::Routine`] on purpose (same rationale as
/// [`SignableSignal`] / [`SignableCheckpointResolution`]): a frozen routine is
/// immutable, so the signature commits to the *frozen* surface only — the
/// routine's identity (`routine_id` / `namespace` / `name`), the content of its
/// template + parameters (each hashed to 32 bytes so the signed payload stays
/// bounded regardless of JSON length — the same bound `SignableSignal` uses for
/// `body_sha256`), and the freeze timestamp. The mutable lifecycle column
/// `state` is excluded because the freeze attestation is meaningful only for a
/// `frozen` row. A captured signature cannot be replayed onto a different
/// routine, a tampered template, or a back-/forward-dated freeze.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignableRoutineFreeze<'a> {
    /// The routine's id (UUIDv4). Pinned so a signature minted for one routine
    /// cannot be replayed onto another.
    pub routine_id: &'a str,
    /// Namespace the routine lives within.
    pub namespace: &'a str,
    /// The routine's name.
    pub name: &'a str,
    /// SHA-256 (32 bytes) over the canonical template JSON string's UTF-8
    /// bytes. Bounds the signed payload size and binds the exact frozen
    /// template — any template tamper after freeze breaks verification.
    pub template_sha256: &'a [u8; 32],
    /// SHA-256 (32 bytes) over the canonical parameters JSON string's UTF-8
    /// bytes. Binds the exact frozen parameter set.
    pub parameters_sha256: &'a [u8; 32],
    /// Epoch-seconds freeze timestamp. Inside the signed surface so a captured
    /// signature cannot be replayed to back- or forward-date a freeze.
    pub frozen_at: i64,
}

/// RFC 8949 §4.2.1 deterministic CBOR encoding of the signable routine-freeze
/// fields.
///
/// Same construction discipline as [`canonical_cbor_signal`]: a
/// `BTreeMap<&str, ciborium::Value>` sorts the keys by construction, both
/// SHA-256 hashes encode as CBOR `bytes`, and `frozen_at` encodes as a CBOR
/// integer. Encoding the same `SignableRoutineFreeze` twice (or on a different
/// host) produces identical bytes — the precondition Ed25519 needs.
///
/// # Errors
///
/// Returns an error only when CBOR serialization fails — in practice
/// unreachable for the fixed-shape input above, surfaced as a `Result` so
/// callers don't have to choose between panicking and silently signing a
/// truncated payload.
pub fn canonical_cbor_routine_freeze(r: &SignableRoutineFreeze<'_>) -> Result<Vec<u8>> {
    use std::collections::BTreeMap;
    let mut map: BTreeMap<&str, ciborium::Value> = BTreeMap::new();
    map.insert(
        "routine_id",
        ciborium::Value::Text(r.routine_id.to_string()),
    );
    map.insert("namespace", ciborium::Value::Text(r.namespace.to_string()));
    map.insert("name", ciborium::Value::Text(r.name.to_string()));
    map.insert(
        "template_sha256",
        ciborium::Value::Bytes(r.template_sha256.to_vec()),
    );
    map.insert(
        "parameters_sha256",
        ciborium::Value::Bytes(r.parameters_sha256.to_vec()),
    );
    map.insert(
        "frozen_at",
        ciborium::Value::Integer(ciborium::value::Integer::from(r.frozen_at)),
    );

    let entries: Vec<(ciborium::Value, ciborium::Value)> = map
        .into_iter()
        .map(|(k, v)| (ciborium::Value::Text(k.to_string()), v))
        .collect();
    let value = ciborium::Value::Map(entries);

    let mut out: Vec<u8> = Vec::with_capacity(256);
    ciborium::ser::into_writer(&value, &mut out).context("CBOR encode SignableRoutineFreeze")?;
    Ok(out)
}

/// Sign a routine freeze with `keypair`'s private key.
///
/// Encodes the freeze attestation via [`canonical_cbor_routine_freeze`], then
/// runs Ed25519 over the resulting bytes. Returns the 64-byte signature, ready
/// to drop into the `signature` BLOB column on the `routines` table.
///
/// # Errors
/// - `keypair.private` is `None` (public-only handle — verification only).
/// - The CBOR encoding step fails (in practice unreachable; surfaced for
///   completeness).
pub fn sign_routine_freeze(
    keypair: &AgentKeypair,
    freeze: &SignableRoutineFreeze<'_>,
) -> Result<Vec<u8>> {
    let signing = keypair.private.as_ref().with_context(|| {
        format!(
            "AgentKeypair for {} has no private key — cannot sign routine freeze",
            keypair.agent_id
        )
    })?;
    let bytes = canonical_cbor_routine_freeze(freeze)?;
    let sig = signing.sign(&bytes);
    Ok(sig.to_bytes().to_vec())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::identity::keypair;
    use ed25519_dalek::Verifier;

    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 canonical_cbor_is_deterministic() {
        // RFC 8949 §4.2.1 — encoding the same logical input three times
        // (in three *different* logical map-key orderings) must produce
        // identical bytes. This is the round-trip precondition for
        // Ed25519 signing AND a regression guard against an encoder
        // upgrade silently switching iteration order.
        //
        // M2 (v0.7.0 round-2): the encoder reads from a `BTreeMap<&str,
        // ...>` which is sorted by construction, so the bytes only ever
        // come out one way regardless of insertion order. We exercise
        // that property explicitly by inserting the six fields in three
        // distinct permutations and asserting all three encodes match.
        // If a future ciborium upgrade changes ordering semantics (or
        // someone swaps the `BTreeMap` for a `HashMap`), this test
        // fires and the maintainer revisits the canonicalisation
        // surface before signatures silently break across versions.

        // The shared field values — same payload, different insertion
        // orders below.
        let src_id = "src-001";
        let dst_id = "dst-002";
        let relation = "related_to";
        let observed_by = Some("alice");
        let valid_from = Some("2026-05-05T00:00:00+00:00");
        let valid_until: Option<&str> = None;

        // Helper: encode by inserting into a *non*-canonical map first
        // (`HashMap`) in a chosen visit order, then producing a
        // canonical `BTreeMap` and round-tripping through
        // `canonical_cbor`.  We can't easily inject our own non-canonical
        // CBOR here without re-writing `canonical_cbor`'s body, but we
        // CAN prove that constructing the same logical input via three
        // distinct intermediate orderings collapses to identical bytes
        // because `canonical_cbor` itself enforces the sort.

        // Permutation 1: declared order (alphabetic-by-construction).
        let perm1 = SignableLink {
            src_id,
            dst_id,
            relation,
            observed_by,
            valid_from,
            valid_until,
        };

        // Permutation 2: same logical link, constructed via field
        // reassignment in a different visual order. Rust struct literal
        // field order is purely syntactic; the binary representation
        // is the same. The encoder must still sort by name.
        let perm2 = SignableLink {
            valid_until,
            valid_from,
            observed_by,
            relation,
            dst_id,
            src_id,
        };

        // Permutation 3: interleaved order.
        let perm3 = SignableLink {
            relation,
            src_id,
            valid_from,
            dst_id,
            valid_until,
            observed_by,
        };

        let bytes1 = canonical_cbor(&perm1).expect("encode perm1");
        let bytes2 = canonical_cbor(&perm2).expect("encode perm2");
        let bytes3 = canonical_cbor(&perm3).expect("encode perm3");

        assert_eq!(
            bytes1, bytes2,
            "field-order permutation 2 must produce identical CBOR (BTreeMap key sort)"
        );
        assert_eq!(
            bytes2, bytes3,
            "field-order permutation 3 must produce identical CBOR (BTreeMap key sort)"
        );

        // Also exercise byte-stability across repeated encodes of the
        // same instance — the property that's load-bearing for sign +
        // verify across hosts.
        let again = canonical_cbor(&perm1).expect("re-encode perm1");
        assert_eq!(bytes1, again, "deterministic CBOR must be byte-stable");
    }

    #[test]
    fn canonical_cbor_differs_on_field_change() {
        // Sanity-check that the encoder isn't flattening fields. Any
        // change in the signed surface should change the byte output.
        let base = link_fixture();
        let mut altered = base.clone();
        altered.relation = "supersedes";
        let a = canonical_cbor(&base).expect("encode base");
        let b = canonical_cbor(&altered).expect("encode altered");
        assert_ne!(a, b, "different relation must produce different bytes");
    }

    #[test]
    fn canonical_cbor_handles_all_optionals_none() {
        let link = SignableLink {
            src_id: "s",
            dst_id: "d",
            relation: "r",
            observed_by: None,
            valid_from: None,
            valid_until: None,
        };
        let bytes = canonical_cbor(&link).expect("encode");
        assert!(!bytes.is_empty());
        // Two encodes still match.
        assert_eq!(bytes, canonical_cbor(&link).expect("re-encode"));
    }

    #[test]
    fn sign_and_verify_round_trip() {
        let kp = keypair::generate("alice").expect("generate");
        let link = link_fixture();
        let sig_bytes = sign(&kp, &link).expect("sign");
        assert_eq!(sig_bytes.len(), 64, "Ed25519 signatures are 64 bytes");

        // Re-derive the canonical bytes and verify with the public key.
        let payload = canonical_cbor(&link).expect("encode");
        let mut sig_arr = [0u8; 64];
        sig_arr.copy_from_slice(&sig_bytes);
        let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
        kp.public.verify(&payload, &sig).expect("verify");
    }

    #[test]
    fn sign_refuses_public_only_keypair() {
        // Public-only handles (load() with no .priv on disk, or list())
        // must not be silently treated as zero-byte signatures — the
        // caller has to fall back to the unsigned path explicitly.
        let kp = keypair::generate("alice").unwrap();
        let pub_only = AgentKeypair {
            agent_id: "alice".to_string(),
            public: kp.public,
            private: None,
        };
        let err = sign(&pub_only, &link_fixture()).unwrap_err();
        let msg = format!("{err:#}");
        assert!(msg.contains("no private key"), "got: {msg}");
    }

    #[test]
    fn sign_differs_for_different_keys() {
        // Two keypairs over the same link produce different signatures
        // (nondeterministic randomness, plus distinct keys).
        let alice = keypair::generate("alice").unwrap();
        let bob = keypair::generate("bob").unwrap();
        let link = link_fixture();
        let sig_a = sign(&alice, &link).unwrap();
        let sig_b = sign(&bob, &link).unwrap();
        assert_ne!(sig_a, sig_b);
    }

    #[test]
    fn signature_does_not_verify_against_other_pub() {
        let alice = keypair::generate("alice").unwrap();
        let bob = keypair::generate("bob").unwrap();
        let link = link_fixture();
        let sig_bytes = sign(&alice, &link).unwrap();
        let payload = canonical_cbor(&link).unwrap();
        let mut sig_arr = [0u8; 64];
        sig_arr.copy_from_slice(&sig_bytes);
        let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
        // Alice's signature must not verify under Bob's public key.
        assert!(bob.public.verify(&payload, &sig).is_err());
    }

    // -----------------------------------------------------------------
    // v0.7.0 issue #812 / #813 — SignablePersona + sign_persona
    // -----------------------------------------------------------------

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

    fn persona_fixture() -> ([u8; 32], Vec<String>) {
        let body = body_hash_fixture(0x10);
        let sources = vec!["src-1".to_string(), "src-2".to_string()];
        (body, sources)
    }

    #[test]
    fn canonical_cbor_persona_is_deterministic() {
        // Mirrors the link-side determinism test: three distinct
        // permutations of the SignablePersona literal must collapse
        // to identical bytes because the BTreeMap key-sort runs at
        // encode time. Catches a regression where a future refactor
        // swaps the BTreeMap for a HashMap or drops the explicit sort.
        let (body, sources) = persona_fixture();
        let persona_id = "persona-001";
        let entity_id = "alice";
        let namespace = "team/alpha";
        let version = 1_i32;
        let generated_at = "2026-05-16T12:00:00+00:00";

        let perm1 = SignablePersona {
            persona_id,
            entity_id,
            namespace,
            version,
            generated_at,
            sources: &sources,
            body_md_sha256: &body,
        };
        let perm2 = SignablePersona {
            body_md_sha256: &body,
            sources: &sources,
            generated_at,
            version,
            namespace,
            entity_id,
            persona_id,
        };
        let perm3 = SignablePersona {
            namespace,
            version,
            sources: &sources,
            entity_id,
            body_md_sha256: &body,
            generated_at,
            persona_id,
        };

        let b1 = canonical_cbor_persona(&perm1).expect("encode perm1");
        let b2 = canonical_cbor_persona(&perm2).expect("encode perm2");
        let b3 = canonical_cbor_persona(&perm3).expect("encode perm3");
        assert_eq!(b1, b2);
        assert_eq!(b2, b3);
        // Stable across repeated encodes of the same instance.
        assert_eq!(b1, canonical_cbor_persona(&perm1).expect("re-encode"));
    }

    #[test]
    fn canonical_cbor_persona_differs_on_field_change() {
        let (body, sources) = persona_fixture();
        let base = SignablePersona {
            persona_id: "p",
            entity_id: "alice",
            namespace: "team/alpha",
            version: 1,
            generated_at: "2026-05-16T00:00:00+00:00",
            sources: &sources,
            body_md_sha256: &body,
        };
        // Flip the body hash — different bytes must result.
        let other_body = body_hash_fixture(0x99);
        let altered = SignablePersona {
            body_md_sha256: &other_body,
            ..base.clone()
        };
        let a = canonical_cbor_persona(&base).expect("encode base");
        let b = canonical_cbor_persona(&altered).expect("encode altered");
        assert_ne!(a, b, "different body hash must produce different bytes");
    }

    #[test]
    fn canonical_cbor_persona_handles_empty_sources() {
        let body = body_hash_fixture(0x01);
        let sources: Vec<String> = Vec::new();
        let persona = SignablePersona {
            persona_id: "p",
            entity_id: "alice",
            namespace: "team/alpha",
            version: 1,
            generated_at: "2026-05-16T00:00:00+00:00",
            sources: &sources,
            body_md_sha256: &body,
        };
        // Encoding must not panic on an empty source list. Two
        // encodes still match (determinism over empty array).
        let bytes = canonical_cbor_persona(&persona).expect("encode empty-sources");
        assert!(!bytes.is_empty());
        assert_eq!(bytes, canonical_cbor_persona(&persona).expect("re-encode"));
    }

    #[test]
    fn sign_persona_round_trip() {
        let kp = keypair::generate("ai:curator").expect("generate");
        let (body, sources) = persona_fixture();
        let persona = SignablePersona {
            persona_id: "persona-xyz",
            entity_id: "alice",
            namespace: "team/alpha",
            version: 1,
            generated_at: "2026-05-16T12:00:00+00:00",
            sources: &sources,
            body_md_sha256: &body,
        };
        let sig_bytes = sign_persona(&kp, &persona).expect("sign");
        assert_eq!(sig_bytes.len(), 64, "Ed25519 signatures are 64 bytes");

        let payload = canonical_cbor_persona(&persona).expect("encode");
        let mut sig_arr = [0u8; 64];
        sig_arr.copy_from_slice(&sig_bytes);
        let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
        kp.public.verify(&payload, &sig).expect("verify");
    }

    #[test]
    fn sign_persona_refuses_public_only_keypair() {
        let kp = keypair::generate("ai:curator").unwrap();
        let pub_only = AgentKeypair {
            agent_id: "ai:curator".to_string(),
            public: kp.public,
            private: None,
        };
        let (body, sources) = persona_fixture();
        let persona = SignablePersona {
            persona_id: "p",
            entity_id: "alice",
            namespace: "team/alpha",
            version: 1,
            generated_at: "2026-05-16T00:00:00+00:00",
            sources: &sources,
            body_md_sha256: &body,
        };
        let err = sign_persona(&pub_only, &persona).unwrap_err();
        let msg = format!("{err:#}");
        assert!(msg.contains("no private key"), "got: {msg}");
    }

    #[test]
    fn sign_persona_does_not_verify_against_other_pub() {
        // Cross-key non-replayability — Alice's signature must not
        // verify under Bob's public key.
        let alice = keypair::generate("alice").unwrap();
        let bob = keypair::generate("bob").unwrap();
        let (body, sources) = persona_fixture();
        let persona = SignablePersona {
            persona_id: "p",
            entity_id: "alice",
            namespace: "team/alpha",
            version: 1,
            generated_at: "2026-05-16T00:00:00+00:00",
            sources: &sources,
            body_md_sha256: &body,
        };
        let sig_bytes = sign_persona(&alice, &persona).unwrap();
        let payload = canonical_cbor_persona(&persona).unwrap();
        let mut sig_arr = [0u8; 64];
        sig_arr.copy_from_slice(&sig_bytes);
        let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
        assert!(bob.public.verify(&payload, &sig).is_err());
    }

    // -----------------------------------------------------------------
    // v0.7.0 #626 Layer-3 (Task 1.3) — SignableWrite + sign_write
    // -----------------------------------------------------------------

    fn write_fixture<'a>(body: &'a [u8; 32]) -> SignableWrite<'a> {
        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 canonical_cbor_write_is_deterministic() {
        // Three distinct permutations of the SignableWrite literal must
        // collapse to identical bytes because the BTreeMap key-sort runs
        // at encode time. Catches a regression where a future refactor
        // swaps the BTreeMap for a HashMap or drops the explicit sort.
        let body = body_hash_fixture(0x20);
        let agent_id = "ai:curator";
        let namespace = "team/alpha";
        let title = "kubernetes deployment guide";
        let kind = "fact";
        let created_at = "2026-06-01T12:00:00+00:00";

        let perm1 = SignableWrite {
            agent_id,
            namespace,
            title,
            kind,
            created_at,
            content_sha256: &body,
        };
        let perm2 = SignableWrite {
            content_sha256: &body,
            created_at,
            kind,
            title,
            namespace,
            agent_id,
        };
        let perm3 = SignableWrite {
            title,
            content_sha256: &body,
            agent_id,
            created_at,
            namespace,
            kind,
        };

        let b1 = canonical_cbor_write(&perm1).expect("encode perm1");
        let b2 = canonical_cbor_write(&perm2).expect("encode perm2");
        let b3 = canonical_cbor_write(&perm3).expect("encode perm3");
        assert_eq!(b1, b2);
        assert_eq!(b2, b3);
        assert_eq!(b1, canonical_cbor_write(&perm1).expect("re-encode"));
    }

    #[test]
    fn canonical_cbor_write_differs_on_field_change() {
        let body = body_hash_fixture(0x21);
        let base = write_fixture(&body);
        // Flip the agent_id — the field the attestation gate binds. A
        // different claimer must produce different signed bytes.
        let altered = SignableWrite {
            agent_id: "ai:impostor",
            ..base.clone()
        };
        let a = canonical_cbor_write(&base).expect("encode base");
        let b = canonical_cbor_write(&altered).expect("encode altered");
        assert_ne!(a, b, "different agent_id must produce different bytes");
    }

    #[test]
    fn canonical_cbor_write_differs_on_content_change() {
        let body = body_hash_fixture(0x22);
        let base = write_fixture(&body);
        let other = body_hash_fixture(0x77);
        let altered = SignableWrite {
            content_sha256: &other,
            ..base.clone()
        };
        let a = canonical_cbor_write(&base).expect("encode base");
        let b = canonical_cbor_write(&altered).expect("encode altered");
        assert_ne!(a, b, "different content hash must produce different bytes");
    }

    #[test]
    fn sign_write_round_trip() {
        let kp = keypair::generate("ai:curator").expect("generate");
        let body = body_hash_fixture(0x23);
        let write = write_fixture(&body);
        let sig_bytes = sign_write(&kp, &write).expect("sign");
        assert_eq!(sig_bytes.len(), 64, "Ed25519 signatures are 64 bytes");

        let payload = canonical_cbor_write(&write).expect("encode");
        let mut sig_arr = [0u8; 64];
        sig_arr.copy_from_slice(&sig_bytes);
        let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
        kp.public.verify(&payload, &sig).expect("verify");
    }

    #[test]
    fn sign_write_refuses_public_only_keypair() {
        let kp = keypair::generate("ai:curator").unwrap();
        let pub_only = AgentKeypair {
            agent_id: "ai:curator".to_string(),
            public: kp.public,
            private: None,
        };
        let body = body_hash_fixture(0x24);
        let write = write_fixture(&body);
        let err = sign_write(&pub_only, &write).unwrap_err();
        let msg = format!("{err:#}");
        assert!(msg.contains("no private key"), "got: {msg}");
    }

    #[test]
    fn sign_write_does_not_verify_against_other_pub() {
        // Cross-key non-replayability — Alice's signature must not verify
        // under Bob's public key. This is the property the attestation
        // gate leans on: a write signed by a non-bound key is rejected.
        let alice = keypair::generate("alice").unwrap();
        let bob = keypair::generate("bob").unwrap();
        let body = body_hash_fixture(0x25);
        let write = write_fixture(&body);
        let sig_bytes = sign_write(&alice, &write).unwrap();
        let payload = canonical_cbor_write(&write).unwrap();
        let mut sig_arr = [0u8; 64];
        sig_arr.copy_from_slice(&sig_bytes);
        let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
        assert!(bob.public.verify(&payload, &sig).is_err());
    }

    #[test]
    fn sign_write_differs_for_different_keys() {
        let alice = keypair::generate("alice").unwrap();
        let bob = keypair::generate("bob").unwrap();
        let body = body_hash_fixture(0x26);
        let write = write_fixture(&body);
        let sig_a = sign_write(&alice, &write).unwrap();
        let sig_b = sign_write(&bob, &write).unwrap();
        assert_ne!(sig_a, sig_b);
    }

    #[test]
    fn canonical_cbor_write_kind_change_produces_different_bytes() {
        // Kind is inside the signed payload so a signature minted for a
        // "fact" write cannot be replayed onto a "plan" write.
        let body = body_hash_fixture(0x27);
        let as_fact = write_fixture(&body);
        let as_plan = SignableWrite {
            kind: "plan",
            ..as_fact.clone()
        };
        let a = canonical_cbor_write(&as_fact).expect("encode fact");
        let b = canonical_cbor_write(&as_plan).expect("encode plan");
        assert_ne!(a, b);
    }

    #[test]
    fn canonical_cbor_persona_version_change_produces_different_bytes() {
        // Version is part of the signed payload so a v1 signature
        // cannot be replayed as a v2 signature — pin that.
        let (body, sources) = persona_fixture();
        let v1 = SignablePersona {
            persona_id: "p",
            entity_id: "alice",
            namespace: "team/alpha",
            version: 1,
            generated_at: "2026-05-16T00:00:00+00:00",
            sources: &sources,
            body_md_sha256: &body,
        };
        let v2 = SignablePersona {
            version: 2,
            ..v1.clone()
        };
        let a = canonical_cbor_persona(&v1).expect("encode v1");
        let b = canonical_cbor_persona(&v2).expect("encode v2");
        assert_ne!(a, b);
    }

    // -----------------------------------------------------------------
    // v0.8.0 Pillar-1 (#1709) — SignableSignal + sign_signal
    // -----------------------------------------------------------------

    fn signal_fixture<'a>(body: &'a [u8; 32]) -> SignableSignal<'a> {
        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 canonical_cbor_signal_is_deterministic() {
        // Three distinct permutations of the SignableSignal literal must
        // collapse to identical bytes because the BTreeMap key-sort runs at
        // encode time. Catches a regression where a future refactor swaps the
        // BTreeMap for a HashMap or drops the explicit sort.
        let body = body_hash_fixture(0x30);
        let id = "sig-001";
        let namespace = "team/alpha";
        let from_agent = "ai:curator";
        let to_agent = Some("ai:planner");
        let subject = "deploy approval";
        let signal_type = "request";
        let in_reply_to: Option<&str> = None;
        let correlation_id = Some("corr-7");
        let created_at = 1_700_000_000_i64;

        let perm1 = SignableSignal {
            id,
            namespace,
            from_agent,
            to_agent,
            subject,
            body_sha256: &body,
            signal_type,
            in_reply_to,
            correlation_id,
            created_at,
        };
        let perm2 = SignableSignal {
            created_at,
            correlation_id,
            in_reply_to,
            signal_type,
            body_sha256: &body,
            subject,
            to_agent,
            from_agent,
            namespace,
            id,
        };
        let perm3 = SignableSignal {
            subject,
            id,
            created_at,
            namespace,
            body_sha256: &body,
            signal_type,
            from_agent,
            correlation_id,
            to_agent,
            in_reply_to,
        };

        let b1 = canonical_cbor_signal(&perm1).expect("encode perm1");
        let b2 = canonical_cbor_signal(&perm2).expect("encode perm2");
        let b3 = canonical_cbor_signal(&perm3).expect("encode perm3");
        assert_eq!(b1, b2);
        assert_eq!(b2, b3);
        assert_eq!(b1, canonical_cbor_signal(&perm1).expect("re-encode"));
    }

    #[test]
    fn canonical_cbor_signal_differs_on_field_change() {
        let body = body_hash_fixture(0x31);
        let base = signal_fixture(&body);
        // Flip the subject — a different subject must produce different bytes.
        let altered = SignableSignal {
            subject: "deploy rejection",
            ..base.clone()
        };
        let a = canonical_cbor_signal(&base).expect("encode base");
        let b = canonical_cbor_signal(&altered).expect("encode altered");
        assert_ne!(a, b, "different subject must produce different bytes");
    }

    #[test]
    fn canonical_cbor_signal_differs_on_body_hash_change() {
        let body = body_hash_fixture(0x32);
        let base = signal_fixture(&body);
        let other = body_hash_fixture(0x88);
        let altered = SignableSignal {
            body_sha256: &other,
            ..base.clone()
        };
        let a = canonical_cbor_signal(&base).expect("encode base");
        let b = canonical_cbor_signal(&altered).expect("encode altered");
        assert_ne!(a, b, "different body hash must produce different bytes");
    }

    #[test]
    fn canonical_cbor_signal_handles_all_optionals_none() {
        let body = body_hash_fixture(0x33);
        let signal = SignableSignal {
            id: "s",
            namespace: "n",
            from_agent: "a",
            to_agent: None,
            subject: "subj",
            body_sha256: &body,
            signal_type: "broadcast",
            in_reply_to: None,
            correlation_id: None,
            created_at: 0,
        };
        let bytes = canonical_cbor_signal(&signal).expect("encode");
        assert!(!bytes.is_empty());
        assert_eq!(bytes, canonical_cbor_signal(&signal).expect("re-encode"));
    }

    #[test]
    fn sign_signal_round_trip() {
        let kp = keypair::generate("ai:curator").expect("generate");
        let body = body_hash_fixture(0x34);
        let signal = signal_fixture(&body);
        let sig_bytes = sign_signal(&kp, &signal).expect("sign");
        assert_eq!(sig_bytes.len(), 64, "Ed25519 signatures are 64 bytes");

        let payload = canonical_cbor_signal(&signal).expect("encode");
        let mut sig_arr = [0u8; 64];
        sig_arr.copy_from_slice(&sig_bytes);
        let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
        kp.public.verify(&payload, &sig).expect("verify");
    }

    #[test]
    fn sign_signal_refuses_public_only_keypair() {
        let kp = keypair::generate("ai:curator").unwrap();
        let pub_only = AgentKeypair {
            agent_id: "ai:curator".to_string(),
            public: kp.public,
            private: None,
        };
        let body = body_hash_fixture(0x35);
        let signal = signal_fixture(&body);
        let err = sign_signal(&pub_only, &signal).unwrap_err();
        let msg = format!("{err:#}");
        assert!(msg.contains("no private key"), "got: {msg}");
    }

    #[test]
    fn sign_signal_does_not_verify_against_other_pub() {
        // Cross-key non-replayability — Alice's signature must not verify
        // under Bob's public key.
        let alice = keypair::generate("alice").unwrap();
        let bob = keypair::generate("bob").unwrap();
        let body = body_hash_fixture(0x36);
        let signal = signal_fixture(&body);
        let sig_bytes = sign_signal(&alice, &signal).unwrap();
        let payload = canonical_cbor_signal(&signal).unwrap();
        let mut sig_arr = [0u8; 64];
        sig_arr.copy_from_slice(&sig_bytes);
        let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
        assert!(bob.public.verify(&payload, &sig).is_err());
    }

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

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

    #[test]
    fn canonical_cbor_checkpoint_resolution_differs_on_field_change() {
        // Any change in the signed surface must change the byte output —
        // otherwise a resolution could be silently re-attributed or its
        // verdict flipped under a stale signature.
        let base = resolution_fixture();
        let mut altered = base.clone();
        altered.state = "rejected";
        let a = canonical_cbor_checkpoint_resolution(&base).expect("encode base");
        let b = canonical_cbor_checkpoint_resolution(&altered).expect("encode altered");
        assert_ne!(a, b, "different state must produce different bytes");

        // And the resolver field is bound too.
        let mut altered_by = base.clone();
        altered_by.resolved_by = "agent-impostor";
        let c = canonical_cbor_checkpoint_resolution(&altered_by).expect("encode altered_by");
        assert_ne!(a, c, "different resolved_by must produce different bytes");
    }

    #[test]
    fn canonical_cbor_checkpoint_resolution_handles_none_resolution() {
        let r = SignableCheckpointResolution {
            checkpoint_id: "c",
            namespace: "n",
            state: "rejected",
            resolved_by: "a",
            resolution: None,
            resolved_at: 0,
        };
        let bytes = canonical_cbor_checkpoint_resolution(&r).expect("encode");
        assert!(!bytes.is_empty());
        // Two encodes still match (byte-stable).
        assert_eq!(
            bytes,
            canonical_cbor_checkpoint_resolution(&r).expect("re-encode")
        );
    }

    #[test]
    fn sign_checkpoint_resolution_round_trip() {
        let kp = keypair::generate("agent-approver").expect("generate");
        let r = resolution_fixture();
        let sig_bytes = sign_checkpoint_resolution(&kp, &r).expect("sign");
        assert_eq!(sig_bytes.len(), 64, "Ed25519 signatures are 64 bytes");

        let payload = canonical_cbor_checkpoint_resolution(&r).expect("encode");
        let mut sig_arr = [0u8; 64];
        sig_arr.copy_from_slice(&sig_bytes);
        let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
        kp.public.verify(&payload, &sig).expect("verify");
    }

    #[test]
    fn sign_checkpoint_resolution_refuses_public_only_keypair() {
        let kp = keypair::generate("agent-approver").unwrap();
        let pub_only = AgentKeypair {
            agent_id: "agent-approver".to_string(),
            public: kp.public,
            private: None,
        };
        let err = sign_checkpoint_resolution(&pub_only, &resolution_fixture()).unwrap_err();
        let msg = format!("{err:#}");
        assert!(msg.contains("no private key"), "got: {msg}");
    }

    #[test]
    fn sign_checkpoint_resolution_does_not_verify_against_other_pub() {
        // Cross-key non-replayability — Alice's resolution signature must not
        // verify under Bob's public key.
        let alice = keypair::generate("alice").unwrap();
        let bob = keypair::generate("bob").unwrap();
        let r = resolution_fixture();
        let sig_bytes = sign_checkpoint_resolution(&alice, &r).unwrap();
        let payload = canonical_cbor_checkpoint_resolution(&r).unwrap();
        let mut sig_arr = [0u8; 64];
        sig_arr.copy_from_slice(&sig_bytes);
        let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
        assert!(bob.public.verify(&payload, &sig).is_err());
    }

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

    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 canonical_cbor_routine_freeze_differs_on_field_change() {
        // Any change in the frozen surface must change the byte output —
        // otherwise a freeze could be silently re-attributed or its template
        // swapped under a stale signature.
        let template = body_hash_fixture(0x40);
        let parameters = body_hash_fixture(0x41);
        let base = routine_freeze_fixture(&template, &parameters);
        let altered = SignableRoutineFreeze {
            name: "rollback",
            ..base.clone()
        };
        let a = canonical_cbor_routine_freeze(&base).expect("encode base");
        let b = canonical_cbor_routine_freeze(&altered).expect("encode altered");
        assert_ne!(a, b, "different name must produce different bytes");

        // The template hash is bound too — a tampered template breaks the bytes.
        let other_template = body_hash_fixture(0x99);
        let altered_template = SignableRoutineFreeze {
            template_sha256: &other_template,
            ..base.clone()
        };
        let c = canonical_cbor_routine_freeze(&altered_template).expect("encode altered_template");
        assert_ne!(a, c, "different template hash must produce different bytes");

        // And the freeze timestamp is bound.
        let altered_at = SignableRoutineFreeze {
            frozen_at: 1_700_009_999,
            ..base.clone()
        };
        let d = canonical_cbor_routine_freeze(&altered_at).expect("encode altered_at");
        assert_ne!(a, d, "different frozen_at must produce different bytes");
    }

    #[test]
    fn canonical_cbor_routine_freeze_is_byte_stable() {
        let template = body_hash_fixture(0x42);
        let parameters = body_hash_fixture(0x43);
        let r = routine_freeze_fixture(&template, &parameters);
        let bytes = canonical_cbor_routine_freeze(&r).expect("encode");
        assert!(!bytes.is_empty());
        assert_eq!(
            bytes,
            canonical_cbor_routine_freeze(&r).expect("re-encode"),
            "deterministic CBOR must be byte-stable"
        );
    }

    #[test]
    fn sign_routine_freeze_round_trip() {
        let kp = keypair::generate("agent-author").expect("generate");
        let template = body_hash_fixture(0x44);
        let parameters = body_hash_fixture(0x45);
        let r = routine_freeze_fixture(&template, &parameters);
        let sig_bytes = sign_routine_freeze(&kp, &r).expect("sign");
        assert_eq!(sig_bytes.len(), 64, "Ed25519 signatures are 64 bytes");

        let payload = canonical_cbor_routine_freeze(&r).expect("encode");
        let mut sig_arr = [0u8; 64];
        sig_arr.copy_from_slice(&sig_bytes);
        let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
        kp.public.verify(&payload, &sig).expect("verify");
    }

    #[test]
    fn sign_routine_freeze_refuses_public_only_keypair() {
        let kp = keypair::generate("agent-author").unwrap();
        let pub_only = AgentKeypair {
            agent_id: "agent-author".to_string(),
            public: kp.public,
            private: None,
        };
        let template = body_hash_fixture(0x46);
        let parameters = body_hash_fixture(0x47);
        let r = routine_freeze_fixture(&template, &parameters);
        let err = sign_routine_freeze(&pub_only, &r).unwrap_err();
        let msg = format!("{err:#}");
        assert!(msg.contains("no private key"), "got: {msg}");
    }

    #[test]
    fn sign_routine_freeze_does_not_verify_against_other_pub() {
        // Cross-key non-replayability — Alice's freeze signature must not
        // verify under Bob's public key.
        let alice = keypair::generate("alice").unwrap();
        let bob = keypair::generate("bob").unwrap();
        let template = body_hash_fixture(0x48);
        let parameters = body_hash_fixture(0x49);
        let r = routine_freeze_fixture(&template, &parameters);
        let sig_bytes = sign_routine_freeze(&alice, &r).unwrap();
        let payload = canonical_cbor_routine_freeze(&r).unwrap();
        let mut sig_arr = [0u8; 64];
        sig_arr.copy_from_slice(&sig_bytes);
        let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
        assert!(bob.public.verify(&payload, &sig).is_err());
    }
}
