verdant-cache-wire 0.2.0

Shared HTTP wire schema for the verdant cache server and RemoteStore client
Documentation
//! verdant-server HTTP wire schema.
//!
//! Four endpoints in M4:
//!
//! - `POST /v1/cache/lookup`     — return the payload for a `(user, key)`
//!   pair, or a `Miss` if absent; for shared-promotable keys the lookup
//!   may fall through to the `_shared` namespace and return the
//!   payload plus `provenance` recording the original author.
//! - `POST /v1/cache/persist`    — write a new payload under
//!   `(user, key)`, optionally promoting to `_shared` when the
//!   server's own root-revalidation accepts the deterministic claim.
//! - `POST /v1/cache/invalidate-upstream` — walk the upstream
//!   dependency edge and drop every entry whose declared upstreams
//!   include the given key, exactly as `LiveCache::invalidate_upstream`
//!   does locally; this is the cross-machine analog so a file edit on
//!   Alice's host invalidates the matching LlmCall entries Bob would
//!   otherwise hit.
//! - `GET  /v1/cache/stats`      — per-user usage and global stats so
//!   clients can render quota state.
//!
//! Auth: every request carries `Authorization: Bearer <token>`; the
//! server resolves the token to a `user_id` and uses that to scope
//! reads and writes. The wire schema does not carry the user id
//! explicitly because the auth layer is the source of truth.
//!
//! This module is the wire shape only; transport binding lives in
//! `server.rs`, storage in `storage.rs`, auth in `auth.rs`. Splitting
//! them lets each layer evolve independently and lets tests exercise
//! the shape without touching disk or a socket.

use serde::{Deserialize, Serialize};
use serde_json::Value;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LookupRequest {
    /// Hex-encoded cache key (matches `verdant-runtime`'s `Key`
    /// validation — 64 lowercase hex chars).
    pub key: String,
    /// When true, the server consults the `_shared` namespace if the
    /// per-user namespace misses. Default `true`; set `false` for a
    /// strictly per-user lookup.
    #[serde(default = "default_true")]
    pub allow_shared: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum LookupResponse {
    /// Entry found. `payload` is base64-encoded raw bytes (small JSON
    /// completions and short tool outputs cross the wire fine inside a
    /// JSON envelope; the cost of base64 inflation is acceptable for
    /// the volumes M4 handles single-tenant).
    Hit {
        payload: String,
        tool_kind: String,
        bytes: u64,
        #[serde(skip_serializing_if = "Option::is_none")]
        provenance: Option<Provenance>,
        #[serde(default)]
        from_shared: bool,
        /// File dependencies recorded at persist time. Defaults to empty
        /// so older servers that omit the field still deserialize. The
        /// client revalidates these against its own checkout before
        /// trusting the payload, which is the only mechanism that
        /// catches cross-machine drift where Bob's tree differs from
        /// Alice's. Server-side revalidation cannot catch this case
        /// because the server only sees its own filesystem.
        #[serde(default)]
        file_roots: Vec<FileRootSpec>,
    },
    Miss,
    /// Entry existed but was invalidated (file root changed, upstream
    /// dropped). The client should treat this identically to `Miss`
    /// for cache-population purposes; the distinct kind lets stats
    /// distinguish "never had it" from "had it, now stale".
    Invalidated,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PersistRequest {
    pub key: String,
    pub tool_kind: String,
    pub payload: String, // base64-encoded
    #[serde(default)]
    pub file_roots: Vec<FileRootSpec>,
    #[serde(default)]
    pub upstream_keys: Vec<String>,
    /// Hint that the entry is safe to promote to `_shared`. The
    /// server still re-verifies file roots before accepting the
    /// promotion; this flag is advisory.
    #[serde(default)]
    pub promote_to_shared: bool,
}

/// One declared file dependency of a cache entry. The `path` is a
/// *workspace-relative* path (e.g., `src/foo.rs`) so the same entry
/// can serve two clients with different absolute checkout layouts.
/// Every consumer joins `path` against its own workspace base before
/// re-hashing on revalidation; this is the mechanism that catches
/// cross-machine drift where Bob's tree differs from Alice's. Legacy
/// absolute paths still revalidate correctly because `PathBuf::join`
/// with an absolute argument replaces the base, so single-machine
/// deployments continue to work without migration.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileRootSpec {
    pub path: String,
    pub expected_hash: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PersistResponse {
    /// Stored successfully under the user's namespace and possibly
    /// promoted to `_shared`.
    Stored {
        promoted_to_shared: bool,
        bytes_used_after: u64,
        bytes_quota: Option<u64>,
    },
    /// Server-side root re-verification failed; the entry is rejected
    /// and not stored anywhere. The `reason` is operator-readable.
    Rejected { reason: String },
    /// Quota exceeded. Reads continue to work; the write is rejected.
    QuotaExceeded { bytes_used: u64, bytes_quota: u64 },
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InvalidateUpstreamRequest {
    pub key: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InvalidateUpstreamResponse {
    pub dropped_count: usize,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StatsResponse {
    pub user_id: String,
    pub user_bytes_used: u64,
    pub user_bytes_quota: Option<u64>,
    pub user_entry_count: u64,
    pub shared_entry_count: u64,
    pub global_bytes_used: u64,
    /// Total bytes currently stored in the `_shared` namespace. Useful
    /// for sizing the eviction cap and for an operator's eyeball
    /// check against `shared_bytes_cap`. Defaults to 0 on a server
    /// that does not track shared bytes (older deploys).
    #[serde(default)]
    pub shared_bytes_used: u64,
    /// Operator-configured byte cap on the `_shared` namespace.
    /// `None` means "no cap"; the cache grows unbounded until disk
    /// fills. `Some` triggers eviction-on-write of oldest-accessed
    /// shared entries when the cap is exceeded.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub shared_bytes_cap: Option<u64>,
    /// Monotonic counter of `_shared` entries evicted since process
    /// boot. A nonzero rate signals the cap is too small for the
    /// working set.
    #[serde(default)]
    pub shared_evictions_total: u64,
}

/// Provenance for a `_shared`-namespace entry: who first persisted
/// the bytes, when, and which version of which tool. Carried on
/// every shared-namespace lookup so a paranoid client can decide
/// whether to trust the upstream author.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Provenance {
    pub author_user_id: String,
    pub author_tool_kind: String,
    pub persisted_at_unix: i64,
    /// Optional free-form notes the persister can stamp onto the
    /// entry; surfaced as-is to readers.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub note: Option<String>,
}

/// Structured error envelope returned on any non-2xx response.
/// Mirrors the OpenAI `error: { message, type }` shape so client
/// libraries that already parse that pattern work without
/// modification.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ErrorEnvelope {
    pub error: ErrorBody,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ErrorBody {
    pub message: String,
    pub r#type: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub details: Option<Value>,
}

fn default_true() -> bool {
    true
}

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

    fn roundtrip<T>(v: &T) -> T
    where
        T: Serialize + for<'de> Deserialize<'de> + PartialEq + std::fmt::Debug,
    {
        let bytes = serde_json::to_vec(v).expect("serialize");
        let decoded: T = serde_json::from_slice(&bytes).expect("deserialize");
        assert_eq!(v, &decoded);
        decoded
    }

    #[test]
    fn lookup_request_default_allow_shared_true() {
        let raw = json!({ "key": "deadbeef".repeat(8) });
        let req: LookupRequest = serde_json::from_value(raw).unwrap();
        assert!(req.allow_shared);
    }

    #[test]
    fn lookup_response_hit_with_provenance_roundtrips() {
        let h = LookupResponse::Hit {
            payload: "aGVsbG8=".into(),
            tool_kind: "read".into(),
            bytes: 5,
            provenance: Some(Provenance {
                author_user_id: "alice".into(),
                author_tool_kind: "read".into(),
                persisted_at_unix: 1_700_000_000,
                note: Some("first read".into()),
            }),
            from_shared: true,
            file_roots: vec![FileRootSpec {
                path: "/abs/path".into(),
                expected_hash: "f".repeat(64),
            }],
        };
        roundtrip(&h);
    }

    #[test]
    fn lookup_response_miss_roundtrips() {
        roundtrip(&LookupResponse::Miss);
    }

    #[test]
    fn lookup_response_invalidated_roundtrips() {
        roundtrip(&LookupResponse::Invalidated);
    }

    #[test]
    fn persist_request_with_upstreams_roundtrips() {
        let req = PersistRequest {
            key: "a".repeat(64),
            tool_kind: "llm_call".into(),
            payload: "Zm9v".into(),
            file_roots: vec![FileRootSpec {
                path: "/abs/path".into(),
                expected_hash: "b".repeat(64),
            }],
            upstream_keys: vec!["c".repeat(64), "d".repeat(64)],
            promote_to_shared: true,
        };
        roundtrip(&req);
    }

    #[test]
    fn persist_response_quota_exceeded_roundtrips() {
        roundtrip(&PersistResponse::QuotaExceeded {
            bytes_used: 1_000_000,
            bytes_quota: 1_048_576,
        });
    }

    #[test]
    fn persist_response_rejected_roundtrips() {
        roundtrip(&PersistResponse::Rejected {
            reason: "file root /abs/path hash mismatch".into(),
        });
    }

    #[test]
    fn invalidate_upstream_roundtrips() {
        roundtrip(&InvalidateUpstreamRequest {
            key: "e".repeat(64),
        });
        roundtrip(&InvalidateUpstreamResponse { dropped_count: 7 });
    }

    #[test]
    fn stats_roundtrips_with_optional_quota_unset() {
        let s = StatsResponse {
            user_id: "alice".into(),
            user_bytes_used: 1024,
            user_bytes_quota: None,
            user_entry_count: 5,
            shared_entry_count: 100,
            global_bytes_used: 1_000_000,
            shared_bytes_used: 500_000,
            shared_bytes_cap: Some(2_000_000_000),
            shared_evictions_total: 42,
        };
        roundtrip(&s);
    }

    #[test]
    fn error_envelope_roundtrips() {
        let e = ErrorEnvelope {
            error: ErrorBody {
                message: "unauthorized".into(),
                r#type: "auth_error".into(),
                details: Some(json!({ "hint": "supply Authorization: Bearer <token>" })),
            },
        };
        roundtrip(&e);
    }

    #[test]
    fn unknown_lookup_response_kind_errors_cleanly() {
        // Future-proof: a wire variant we don't know about should
        // fail to deserialize rather than silently match the wrong
        // case. We assert by attempting to parse a clearly-novel
        // shape and expecting an error.
        let raw = json!({ "kind": "future_variant", "payload": "x" });
        let err = serde_json::from_value::<LookupResponse>(raw).unwrap_err();
        assert!(err.to_string().contains("future_variant"));
    }
}