klieo-ops 3.5.0

Operational layer above klieo-core: supervisor, governor, gates, escalation, worklog, handoff.
Documentation
//! Shared Ed25519 key-lookup registry generic over the ID type.
//!
//! Used by both `ApproverRegistry` (keyed by `ApproverId`) and
//! `SourceIdentityRegistry` (keyed by `AgentId`). The trait surface is
//! intentionally separate per call-site to keep cross-domain keyspaces
//! distinct in the type system.

use ed25519_dalek::VerifyingKey;
use std::collections::HashMap;
use std::path::Path;
use thiserror::Error;

/// Errors raised by registry loaders.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum KeyRegistryError {
    /// YAML load / parse failure.
    #[error("key registry load: {0}")]
    Load(String),
    /// Pubkey not a valid 32-byte Ed25519 verifying key.
    #[error("key registry: pubkey for `{id}` is not a valid 32-byte Ed25519 key")]
    BadKey {
        /// Offending id (display string).
        id: String,
    },
}

/// Common Ed25519 key registry, generic over the ID newtype.
pub trait Ed25519KeyRegistry<Id>: Send + Sync
where
    Id: Eq + std::hash::Hash + std::fmt::Display,
{
    /// Resolve `id` to a trusted Ed25519 verifying key, or `None` if
    /// the id is not registered.
    fn lookup(&self, id: &Id) -> Option<VerifyingKey>;
}

/// In-memory `HashMap`-backed registry.
pub struct StaticEd25519KeyRegistry<Id>
where
    Id: Eq + std::hash::Hash + std::fmt::Display,
{
    keys: HashMap<Id, VerifyingKey>,
}

impl<Id> StaticEd25519KeyRegistry<Id>
where
    Id: Eq + std::hash::Hash + std::fmt::Display,
{
    /// Build from an in-memory map.
    #[must_use]
    pub fn from_map(keys: HashMap<Id, VerifyingKey>) -> Self {
        Self { keys }
    }
}

impl<Id> Ed25519KeyRegistry<Id> for StaticEd25519KeyRegistry<Id>
where
    Id: Eq + std::hash::Hash + std::fmt::Display + Send + Sync,
{
    fn lookup(&self, id: &Id) -> Option<VerifyingKey> {
        self.keys.get(id).copied()
    }
}

/// Load a YAML map of id → base64-encoded Ed25519 verifying key.
///
/// The `make_id` closure constructs the concrete ID newtype from the
/// raw string key. Used by `StaticApproverRegistry::from_yaml` and
/// `StaticSourceIdentityRegistry::from_yaml`.
pub fn load_ed25519_yaml<Id>(
    path: impl AsRef<Path>,
    make_id: impl Fn(String) -> Id,
) -> Result<HashMap<Id, VerifyingKey>, KeyRegistryError>
where
    Id: Eq + std::hash::Hash + std::fmt::Display,
{
    let body = std::fs::read_to_string(path.as_ref())
        .map_err(|e| KeyRegistryError::Load(format!("read: {e}")))?;
    let raw: HashMap<String, String> =
        serde_yaml::from_str(&body).map_err(|e| KeyRegistryError::Load(format!("parse: {e}")))?;
    let mut keys = HashMap::with_capacity(raw.len());
    for (id_raw, b64) in raw {
        use base64::Engine as _;
        let bytes = base64::engine::general_purpose::STANDARD
            .decode(b64)
            .map_err(|e| KeyRegistryError::Load(format!("base64 `{id_raw}`: {e}")))?;
        let id = make_id(id_raw);
        let pk_bytes: [u8; 32] = bytes
            .try_into()
            .map_err(|_| KeyRegistryError::BadKey { id: id.to_string() })?;
        let vk = VerifyingKey::from_bytes(&pk_bytes)
            .map_err(|_| KeyRegistryError::BadKey { id: id.to_string() })?;
        keys.insert(id, vk);
    }
    Ok(keys)
}

#[cfg(feature = "hot-reload")]
pub use hot_reload::HotReloadableEd25519Registry;

#[cfg(feature = "hot-reload")]
mod hot_reload {
    //! YAML-watching hot-reload wrapper around `Ed25519KeyRegistry<Id>`.
    //!
    //! Wraps an `Arc<HashMap>` behind `arc-swap` so reads are wait-free.
    //! On file modification the `notify-debouncer-mini` debouncer aggregates
    //! rapid filesystem events into a single callback (500ms window), then
    //! re-parses and atomically swaps the in-memory map. Parse errors keep
    //! the previous snapshot and emit a `tracing::warn!` — the registry never
    //! panics or serves a partially-loaded state.
    //!
    //! The debounce logic runs on notify-debouncer-mini's own thread rather
    //! than the notify dispatcher thread, which means file saves no longer
    //! serialise subsequent watcher events behind a `thread::sleep`.

    use super::{load_ed25519_yaml, Ed25519KeyRegistry, KeyRegistryError};
    use arc_swap::ArcSwap;
    use ed25519_dalek::VerifyingKey;
    use notify_debouncer_mini::{new_debouncer, notify, DebounceEventResult};
    use std::collections::HashMap;
    use std::path::Path;
    use std::sync::Arc;
    use std::time::Duration;

    /// Hot-reloading wrapper around an in-memory Ed25519 key registry.
    ///
    /// Construct with [`HotReloadableEd25519Registry::watch_yaml`].
    /// Uses `notify-debouncer-mini` so the 500ms debounce window runs off the
    /// notify dispatcher thread; high-frequency file events do not stall
    /// subsequent watcher callbacks. Reads via [`Ed25519KeyRegistry::lookup`]
    /// are wait-free and never block.
    #[non_exhaustive]
    pub struct HotReloadableEd25519Registry<Id>
    where
        Id: Eq + std::hash::Hash + std::fmt::Display + Send + Sync + 'static,
    {
        keys: Arc<ArcSwap<HashMap<Id, VerifyingKey>>>,
        // Keep the debouncer alive for the full lifetime of the registry.
        _debouncer: notify_debouncer_mini::Debouncer<notify::RecommendedWatcher>,
    }

    impl<Id> HotReloadableEd25519Registry<Id>
    where
        Id: Eq + std::hash::Hash + std::fmt::Display + Send + Sync + Clone + 'static,
    {
        /// Build from a YAML file path.
        ///
        /// Performs an initial load (fails fast on parse error) then starts
        /// watching the file for changes. `make_id` constructs the concrete
        /// ID newtype from the raw YAML key string.
        pub fn watch_yaml(
            path: impl AsRef<Path>,
            make_id: impl Fn(String) -> Id + Send + Sync + 'static,
        ) -> Result<Self, KeyRegistryError> {
            let path = path.as_ref().to_path_buf();
            let initial = load_ed25519_yaml(&path, &make_id)?;
            let keys = Arc::new(ArcSwap::from_pointee(initial));

            let keys_for_watch = keys.clone();
            let path_for_watch = path.clone();
            let make_id_arc: Arc<dyn Fn(String) -> Id + Send + Sync> = Arc::new(make_id);

            let mut debouncer = new_debouncer(
                Duration::from_millis(500),
                move |result: DebounceEventResult| {
                    match result {
                        Err(err) => {
                            tracing::warn!(
                                target: "klieo.ops.key_registry.hot_reload",
                                error = %err,
                                "watcher error; keeping previous snapshot"
                            );
                            return;
                        }
                        Ok(events) => {
                            // Only act on modify/create events; ignore access/remove.
                            let relevant = events.iter().any(|e| {
                                matches!(e.kind, notify_debouncer_mini::DebouncedEventKind::Any)
                            });
                            if !relevant {
                                return;
                            }
                        }
                    }
                    match load_ed25519_yaml(&path_for_watch, |s| (make_id_arc)(s)) {
                        Ok(new_keys) => {
                            keys_for_watch.store(Arc::new(new_keys));
                            tracing::info!(
                                target: "klieo.ops.key_registry.hot_reload",
                                path = %path_for_watch.display(),
                                "key registry reloaded"
                            );
                        }
                        Err(err) => {
                            tracing::warn!(
                                target: "klieo.ops.key_registry.hot_reload",
                                path = %path_for_watch.display(),
                                error = %err,
                                "reload failed; keeping previous snapshot"
                            );
                        }
                    }
                },
            )
            .map_err(|e| KeyRegistryError::Load(format!("debouncer init: {e}")))?;

            debouncer
                .watcher()
                .watch(&path, notify::RecursiveMode::NonRecursive)
                .map_err(|e| KeyRegistryError::Load(format!("watch start: {e}")))?;

            Ok(Self {
                keys,
                _debouncer: debouncer,
            })
        }
    }

    impl<Id> Ed25519KeyRegistry<Id> for HotReloadableEd25519Registry<Id>
    where
        Id: Eq + std::hash::Hash + std::fmt::Display + Send + Sync + 'static,
    {
        fn lookup(&self, id: &Id) -> Option<VerifyingKey> {
            self.keys.load().get(id).copied()
        }
    }
}