klieo-ops 3.5.0

Operational layer above klieo-core: supervisor, governor, gates, escalation, worklog, handoff.
Documentation
//! `Handoff` trait and supporting types.

use super::envelope::{HandoffEnvelope, HandoffProof};
use crate::types::{AgentId, TenantId};
use async_trait::async_trait;
use thiserror::Error;

/// Inputs to `Handoff::package`.
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct HandoffState {
    /// Source `RunId`.
    pub from_run: String,
    /// Hex-encoded Merkle root at packaging time.
    pub merkle_root: String,
    /// Optional source-run sequence-number anchor.
    pub at_seq: Option<u64>,
    /// CBOR-serialised and redacted state body. Caller must pre-redact.
    pub redacted_state: Vec<u8>,
    /// Source agent identity.
    pub source_identity: AgentId,
    /// Tenant tag.
    pub tenant: Option<TenantId>,
}

impl HandoffState {
    /// Construct a `HandoffState`. Use this instead of struct literal syntax —
    /// `#[non_exhaustive]` blocks cross-crate struct literals.
    pub fn new(
        from_run: impl Into<String>,
        merkle_root: impl Into<String>,
        redacted_state: Vec<u8>,
        source_identity: AgentId,
        tenant: Option<TenantId>,
        at_seq: Option<u64>,
    ) -> Self {
        Self {
            from_run: from_run.into(),
            merkle_root: merkle_root.into(),
            at_seq,
            redacted_state,
            source_identity,
            tenant,
        }
    }
}

/// Errors raised by `Handoff` calls.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum HandoffError {
    /// Envelope has expired.
    #[error("envelope expired at {expired_at}")]
    Expired {
        /// RFC 3339 expiry timestamp.
        expired_at: String,
    },
    /// Signature is mathematically invalid.
    #[error("signature invalid: {0}")]
    SignatureInvalid(String),
    /// Source identity is not in the receiver's trust registry.
    #[error("untrusted source identity: {0}")]
    UntrustedSource(String),
    /// CBOR or canonical-JSON serialisation failed.
    #[error("serialisation: {0}")]
    Serialisation(String),
    /// KV storage write failed.
    #[error("storage unavailable: {0}")]
    Storage(String),
    /// Unexpected internal error.
    #[error("internal: {0}")]
    Internal(String),
    /// Opaque rejection for public-boundary callers. Collapses
    /// `SignatureInvalid` and `UntrustedSource` into a single error so
    /// remote callers cannot determine registry membership from the
    /// error variant. `reason` is intentionally empty at the network
    /// boundary; internal callers may populate it for diagnostics.
    #[error("unverifiable")]
    Unverifiable {
        /// Diagnostic reason — empty string at the public boundary,
        /// non-empty for internal / operator tooling only.
        reason: String,
    },
}

/// Handoff primitive: package, deliver, and verify signed state-transfer
/// envelopes between agent runs.
///
/// Distinct from `klieo-a2a` (in-flight RPC within one workflow) and
/// `klieo-runlog` replay (post-hoc inspection). See spec § 1.7.
#[async_trait]
pub trait Handoff: Send + Sync {
    /// Package a handoff envelope. Signs the canonical payload via the
    /// supplied `SignerProvider`. Callers must pre-redact the state and
    /// CBOR-serialise it before passing `redacted_state` in `HandoffState`.
    async fn package(
        &self,
        state: HandoffState,
        signer: &dyn crate::signer::SignerProvider,
        ttl: chrono::Duration,
    ) -> Result<HandoffEnvelope, HandoffError>;

    /// Persist and deliver an envelope to a receiver agent. Returns the
    /// source `RunId` the receiver should adopt.
    async fn deliver(&self, envelope: HandoffEnvelope, to: AgentId)
        -> Result<String, HandoffError>;

    /// Verify-before-adopt: checks signature math, expiry, and (when
    /// configured) source-identity trust. Returns a `HandoffProof` on
    /// success — the only way to obtain one.
    async fn verify(&self, envelope: &HandoffEnvelope) -> Result<HandoffProof, HandoffError>;

    /// Public-boundary variant of `verify` that collapses signature-math
    /// failures and trust-registry failures into a single opaque error.
    /// Prevents identity-membership oracle for remote callers.
    ///
    /// `Expired`, `Serialisation`, `Storage`, and `Internal` pass through
    /// unchanged — they do not leak identity membership information.
    /// `SignatureInvalid` and `UntrustedSource` are both mapped to
    /// `HandoffError::Unverifiable { reason: "" }` with an empty reason
    /// so no information leaks via the error payload either.
    ///
    /// Remote-facing services should prefer this method. Internal callers
    /// (audit tools, operator dashboards) should use `verify` directly to
    /// preserve the diagnostic distinction.
    async fn verify_opaque(
        &self,
        envelope: &HandoffEnvelope,
    ) -> Result<HandoffProof, HandoffError> {
        match self.verify(envelope).await {
            Ok(proof) => Ok(proof),
            Err(HandoffError::SignatureInvalid(_)) | Err(HandoffError::UntrustedSource(_)) => {
                Err(HandoffError::Unverifiable {
                    reason: String::new(),
                })
            }
            Err(other) => Err(other),
        }
    }
}