openlatch-client 0.1.18

OpenLatch runtime enforcement node — the capture-and-enforce client for the AI Operations Platform
//! Bounded, local, on-disk retention store for churning prefix blocks.
//!
//! Backs `openlatch boundary explain <finding_id>` (C-10b): the churn
//! *classification* travels on the wire, but the churning **block content stays
//! on the originating host** (F-34). This store keeps that content locally with
//! three explicit bounds — a **size cap**, an **age cap**, and a **documented
//! location** — so it can never become the "unbounded local prompt store" the
//! verification pass flagged.
//!
//! | Bound | Value |
//! | ----- | ----- |
//! | Location | `~/.openlatch/boundary/findings/` |
//! | Size cap | at most [`MAX_FINDINGS`] files (oldest pruned first) |
//! | Age cap | entries older than [`MAX_AGE`] are deleted by the periodic prune |
//!
//! Each finding is a single small JSON file `{finding_id}.json`. Writes are
//! best-effort, **atomic** (tmp + rename), and kept **off the synchronous
//! forward path**: a retention failure — or its blocking disk I/O — must never
//! affect the forward or add to time-to-first-byte.

use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, SystemTime};

use serde::{Deserialize, Serialize};

/// At most this many finding files are retained; the oldest are pruned first.
pub const MAX_FINDINGS: usize = 512;

/// Findings older than this are deleted by the periodic store-side prune.
pub const MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60);

/// The size/age prune runs only every Nth store, never on every request: a full
/// `read_dir` + stat-per-file sweep is too heavy for the hot path (and prefix
/// churn fires on most requests). The caps are still enforced, just amortised.
const PRUNE_INTERVAL: usize = 32;

/// Monotonic store counter driving the periodic prune (see [`PRUNE_INTERVAL`]).
static STORE_COUNT: AtomicUsize = AtomicUsize::new(0);

/// A locally-retained churn block. **Never emitted** — only shown by
/// `boundary explain` on this host.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FindingRecord {
    pub finding_id: String,
    /// RFC3339 capture time.
    pub captured_at: String,
    pub churn_layer: String,
    pub churn_class: String,
    pub divergence_offset: u64,
    pub churn_byte_len: u64,
    pub churn_block_index: u64,
    /// The actual churning block content (host-local only).
    pub block: String,
}

/// The findings directory: `~/.openlatch/boundary/findings/`.
pub fn findings_dir() -> PathBuf {
    crate::config::openlatch_dir()
        .join("boundary")
        .join("findings")
}

/// Persist a finding, best-effort. The write is **atomic** (tmp + rename, so a
/// concurrent `boundary explain` read never sees a partial file) and runs **off
/// the synchronous forward path**: when a tokio runtime is available (production
/// and async tests) the write and occasional prune are handed to the blocking
/// pool fire-and-forget (never awaited, `JoinHandle` dropped); with no runtime
/// (unit tests) they run inline so the file is observable immediately. Errors
/// are swallowed — retention must never break the forward path.
pub fn store(record: &FindingRecord) {
    if tokio::runtime::Handle::try_current().is_ok() {
        // Defer the blocking disk I/O so it never adds to time-to-first-byte.
        let record = record.clone();
        tokio::task::spawn_blocking(move || write_finding(&record));
    } else {
        write_finding(record);
    }
}

/// Atomic write + periodic prune — the actual disk work, shared by the deferred
/// (spawn_blocking) and inline (no-runtime) paths.
fn write_finding(record: &FindingRecord) {
    let dir = findings_dir();
    if std::fs::create_dir_all(&dir).is_err() {
        return;
    }
    let path = dir.join(format!("{}.json", sanitize(&record.finding_id)));
    if let Ok(json) = serde_json::to_string(record) {
        // Atomic: write a temp sibling then rename, so a reader sees either the
        // old file or the complete new one — never a torn write (mirrors
        // `core::install_state::save_to`).
        let tmp = path.with_extension("json.tmp");
        if std::fs::write(&tmp, json).is_ok() {
            let _ = std::fs::rename(&tmp, &path);
        }
    }
    // Prune only every Nth store, not per request. `fetch_add` returns the prior
    // value, so this fires on stores 0, 32, 64, … enforcing the size/age caps
    // periodically without a per-store `read_dir`.
    if STORE_COUNT
        .fetch_add(1, Ordering::Relaxed)
        .is_multiple_of(PRUNE_INTERVAL)
    {
        prune(&dir);
    }
}

/// Look up a finding by id for `boundary explain`. Does **not** prune on the read
/// path — the size/age caps are enforced by the periodic store-side prune, so a
/// hot read stays a single `fs::read` with no directory scan.
pub fn load(finding_id: &str) -> Option<FindingRecord> {
    let dir = findings_dir();
    let path = dir.join(format!("{}.json", sanitize(finding_id)));
    let bytes = std::fs::read(&path).ok()?;
    serde_json::from_slice(&bytes).ok()
}

/// Enforce the age cap (delete expired) then the size cap (delete oldest until
/// within [`MAX_FINDINGS`]).
fn prune(dir: &std::path::Path) {
    let Ok(read) = std::fs::read_dir(dir) else {
        return;
    };
    let now = SystemTime::now();
    let mut entries: Vec<(SystemTime, PathBuf)> = Vec::new();
    for e in read.flatten() {
        let path = e.path();
        if path.extension().and_then(|x| x.to_str()) != Some("json") {
            continue;
        }
        let modified = e
            .metadata()
            .and_then(|m| m.modified())
            .unwrap_or(SystemTime::UNIX_EPOCH);
        // Age cap.
        if now
            .duration_since(modified)
            .map(|age| age > MAX_AGE)
            .unwrap_or(false)
        {
            let _ = std::fs::remove_file(&path);
            continue;
        }
        entries.push((modified, path));
    }
    // Size cap: drop the oldest beyond MAX_FINDINGS.
    if entries.len() > MAX_FINDINGS {
        entries.sort_by_key(|(t, _)| *t);
        let excess = entries.len() - MAX_FINDINGS;
        for (_, path) in entries.into_iter().take(excess) {
            let _ = std::fs::remove_file(&path);
        }
    }
}

/// Keep a finding id filesystem-safe (it is a locally-minted UUID, but never
/// trust an id used to build a path).
fn sanitize(id: &str) -> String {
    id.chars()
        .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
        .take(128)
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    fn rec(id: &str) -> FindingRecord {
        FindingRecord {
            finding_id: id.to_string(),
            captured_at: "2026-07-23T00:00:00Z".to_string(),
            churn_layer: "messages".to_string(),
            churn_class: "timestamp".to_string(),
            divergence_offset: 10,
            churn_byte_len: 20,
            churn_block_index: 1,
            block: "the local block content".to_string(),
        }
    }

    #[test]
    fn sanitize_strips_path_traversal() {
        assert_eq!(sanitize("../../etc/passwd"), "etcpasswd");
        assert_eq!(sanitize("fnd_abc-123"), "fnd_abc-123");
    }

    #[test]
    fn store_and_load_roundtrip() {
        // Isolate the openlatch dir so the real ~/.openlatch is untouched.
        let tmp = tempfile::tempdir().unwrap();
        std::env::set_var("OPENLATCH_DIR", tmp.path());
        let r = rec("fnd_roundtrip");
        store(&r);
        let got = load("fnd_roundtrip").expect("finding must load");
        assert_eq!(got.block, r.block);
        assert_eq!(got.churn_class, "timestamp");
        assert!(load("fnd_absent").is_none());
        std::env::remove_var("OPENLATCH_DIR");
    }
}