Skip to main content

verdant_wire/
lib.rs

1//! verdant-server HTTP wire schema.
2//!
3//! Four endpoints in M4:
4//!
5//! - `POST /v1/cache/lookup`     — return the payload for a `(user, key)`
6//!   pair, or a `Miss` if absent; for shared-promotable keys the lookup
7//!   may fall through to the `_shared` namespace and return the
8//!   payload plus `provenance` recording the original author.
9//! - `POST /v1/cache/persist`    — write a new payload under
10//!   `(user, key)`, optionally promoting to `_shared` when the
11//!   server's own root-revalidation accepts the deterministic claim.
12//! - `POST /v1/cache/invalidate-upstream` — walk the upstream
13//!   dependency edge and drop every entry whose declared upstreams
14//!   include the given key, exactly as `LiveCache::invalidate_upstream`
15//!   does locally; this is the cross-machine analog so a file edit on
16//!   Alice's host invalidates the matching LlmCall entries Bob would
17//!   otherwise hit.
18//! - `GET  /v1/cache/stats`      — per-user usage and global stats so
19//!   clients can render quota state.
20//!
21//! Auth: every request carries `Authorization: Bearer <token>`; the
22//! server resolves the token to a `user_id` and uses that to scope
23//! reads and writes. The wire schema does not carry the user id
24//! explicitly because the auth layer is the source of truth.
25//!
26//! This module is the wire shape only; transport binding lives in
27//! `server.rs`, storage in `storage.rs`, auth in `auth.rs`. Splitting
28//! them lets each layer evolve independently and lets tests exercise
29//! the shape without touching disk or a socket.
30
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct LookupRequest {
36    /// Hex-encoded cache key (matches `verdant-runtime`'s `Key`
37    /// validation — 64 lowercase hex chars).
38    pub key: String,
39    /// When true, the server consults the `_shared` namespace if the
40    /// per-user namespace misses. Default `true`; set `false` for a
41    /// strictly per-user lookup.
42    #[serde(default = "default_true")]
43    pub allow_shared: bool,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(tag = "kind", rename_all = "snake_case")]
48pub enum LookupResponse {
49    /// Entry found. `payload` is base64-encoded raw bytes (small JSON
50    /// completions and short tool outputs cross the wire fine inside a
51    /// JSON envelope; the cost of base64 inflation is acceptable for
52    /// the volumes M4 handles single-tenant).
53    Hit {
54        payload: String,
55        tool_kind: String,
56        bytes: u64,
57        #[serde(skip_serializing_if = "Option::is_none")]
58        provenance: Option<Provenance>,
59        #[serde(default)]
60        from_shared: bool,
61        /// File dependencies recorded at persist time. Defaults to empty
62        /// so older servers that omit the field still deserialize. The
63        /// client revalidates these against its own checkout before
64        /// trusting the payload, which is the only mechanism that
65        /// catches cross-machine drift where Bob's tree differs from
66        /// Alice's. Server-side revalidation cannot catch this case
67        /// because the server only sees its own filesystem.
68        #[serde(default)]
69        file_roots: Vec<FileRootSpec>,
70    },
71    Miss,
72    /// Entry existed but was invalidated (file root changed, upstream
73    /// dropped). The client should treat this identically to `Miss`
74    /// for cache-population purposes; the distinct kind lets stats
75    /// distinguish "never had it" from "had it, now stale".
76    Invalidated,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct PersistRequest {
81    pub key: String,
82    pub tool_kind: String,
83    pub payload: String, // base64-encoded
84    #[serde(default)]
85    pub file_roots: Vec<FileRootSpec>,
86    #[serde(default)]
87    pub upstream_keys: Vec<String>,
88    /// Hint that the entry is safe to promote to `_shared`. The
89    /// server still re-verifies file roots before accepting the
90    /// promotion; this flag is advisory.
91    #[serde(default)]
92    pub promote_to_shared: bool,
93}
94
95/// One declared file dependency of a cache entry. The `path` is a
96/// *workspace-relative* path (e.g., `src/foo.rs`) so the same entry
97/// can serve two clients with different absolute checkout layouts.
98/// Every consumer joins `path` against its own workspace base before
99/// re-hashing on revalidation; this is the mechanism that catches
100/// cross-machine drift where Bob's tree differs from Alice's. Legacy
101/// absolute paths still revalidate correctly because `PathBuf::join`
102/// with an absolute argument replaces the base, so single-machine
103/// deployments continue to work without migration.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct FileRootSpec {
106    pub path: String,
107    pub expected_hash: String,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(tag = "kind", rename_all = "snake_case")]
112pub enum PersistResponse {
113    /// Stored successfully under the user's namespace and possibly
114    /// promoted to `_shared`.
115    Stored {
116        promoted_to_shared: bool,
117        bytes_used_after: u64,
118        bytes_quota: Option<u64>,
119    },
120    /// Server-side root re-verification failed; the entry is rejected
121    /// and not stored anywhere. The `reason` is operator-readable.
122    Rejected { reason: String },
123    /// Quota exceeded. Reads continue to work; the write is rejected.
124    QuotaExceeded { bytes_used: u64, bytes_quota: u64 },
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128pub struct InvalidateUpstreamRequest {
129    pub key: String,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct InvalidateUpstreamResponse {
134    pub dropped_count: usize,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct StatsResponse {
139    pub user_id: String,
140    pub user_bytes_used: u64,
141    pub user_bytes_quota: Option<u64>,
142    pub user_entry_count: u64,
143    pub shared_entry_count: u64,
144    pub global_bytes_used: u64,
145    /// Total bytes currently stored in the `_shared` namespace. Useful
146    /// for sizing the eviction cap and for an operator's eyeball
147    /// check against `shared_bytes_cap`. Defaults to 0 on a server
148    /// that does not track shared bytes (older deploys).
149    #[serde(default)]
150    pub shared_bytes_used: u64,
151    /// Operator-configured byte cap on the `_shared` namespace.
152    /// `None` means "no cap"; the cache grows unbounded until disk
153    /// fills. `Some` triggers eviction-on-write of oldest-accessed
154    /// shared entries when the cap is exceeded.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub shared_bytes_cap: Option<u64>,
157    /// Monotonic counter of `_shared` entries evicted since process
158    /// boot. A nonzero rate signals the cap is too small for the
159    /// working set.
160    #[serde(default)]
161    pub shared_evictions_total: u64,
162}
163
164/// Provenance for a `_shared`-namespace entry: who first persisted
165/// the bytes, when, and which version of which tool. Carried on
166/// every shared-namespace lookup so a paranoid client can decide
167/// whether to trust the upstream author.
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct Provenance {
170    pub author_user_id: String,
171    pub author_tool_kind: String,
172    pub persisted_at_unix: i64,
173    /// Optional free-form notes the persister can stamp onto the
174    /// entry; surfaced as-is to readers.
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub note: Option<String>,
177}
178
179/// Structured error envelope returned on any non-2xx response.
180/// Mirrors the OpenAI `error: { message, type }` shape so client
181/// libraries that already parse that pattern work without
182/// modification.
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184pub struct ErrorEnvelope {
185    pub error: ErrorBody,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189pub struct ErrorBody {
190    pub message: String,
191    pub r#type: String,
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub details: Option<Value>,
194}
195
196fn default_true() -> bool {
197    true
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use serde_json::json;
204
205    fn roundtrip<T>(v: &T) -> T
206    where
207        T: Serialize + for<'de> Deserialize<'de> + PartialEq + std::fmt::Debug,
208    {
209        let bytes = serde_json::to_vec(v).expect("serialize");
210        let decoded: T = serde_json::from_slice(&bytes).expect("deserialize");
211        assert_eq!(v, &decoded);
212        decoded
213    }
214
215    #[test]
216    fn lookup_request_default_allow_shared_true() {
217        let raw = json!({ "key": "deadbeef".repeat(8) });
218        let req: LookupRequest = serde_json::from_value(raw).unwrap();
219        assert!(req.allow_shared);
220    }
221
222    #[test]
223    fn lookup_response_hit_with_provenance_roundtrips() {
224        let h = LookupResponse::Hit {
225            payload: "aGVsbG8=".into(),
226            tool_kind: "read".into(),
227            bytes: 5,
228            provenance: Some(Provenance {
229                author_user_id: "alice".into(),
230                author_tool_kind: "read".into(),
231                persisted_at_unix: 1_700_000_000,
232                note: Some("first read".into()),
233            }),
234            from_shared: true,
235            file_roots: vec![FileRootSpec {
236                path: "/abs/path".into(),
237                expected_hash: "f".repeat(64),
238            }],
239        };
240        roundtrip(&h);
241    }
242
243    #[test]
244    fn lookup_response_miss_roundtrips() {
245        roundtrip(&LookupResponse::Miss);
246    }
247
248    #[test]
249    fn lookup_response_invalidated_roundtrips() {
250        roundtrip(&LookupResponse::Invalidated);
251    }
252
253    #[test]
254    fn persist_request_with_upstreams_roundtrips() {
255        let req = PersistRequest {
256            key: "a".repeat(64),
257            tool_kind: "llm_call".into(),
258            payload: "Zm9v".into(),
259            file_roots: vec![FileRootSpec {
260                path: "/abs/path".into(),
261                expected_hash: "b".repeat(64),
262            }],
263            upstream_keys: vec!["c".repeat(64), "d".repeat(64)],
264            promote_to_shared: true,
265        };
266        roundtrip(&req);
267    }
268
269    #[test]
270    fn persist_response_quota_exceeded_roundtrips() {
271        roundtrip(&PersistResponse::QuotaExceeded {
272            bytes_used: 1_000_000,
273            bytes_quota: 1_048_576,
274        });
275    }
276
277    #[test]
278    fn persist_response_rejected_roundtrips() {
279        roundtrip(&PersistResponse::Rejected {
280            reason: "file root /abs/path hash mismatch".into(),
281        });
282    }
283
284    #[test]
285    fn invalidate_upstream_roundtrips() {
286        roundtrip(&InvalidateUpstreamRequest {
287            key: "e".repeat(64),
288        });
289        roundtrip(&InvalidateUpstreamResponse { dropped_count: 7 });
290    }
291
292    #[test]
293    fn stats_roundtrips_with_optional_quota_unset() {
294        let s = StatsResponse {
295            user_id: "alice".into(),
296            user_bytes_used: 1024,
297            user_bytes_quota: None,
298            user_entry_count: 5,
299            shared_entry_count: 100,
300            global_bytes_used: 1_000_000,
301            shared_bytes_used: 500_000,
302            shared_bytes_cap: Some(2_000_000_000),
303            shared_evictions_total: 42,
304        };
305        roundtrip(&s);
306    }
307
308    #[test]
309    fn error_envelope_roundtrips() {
310        let e = ErrorEnvelope {
311            error: ErrorBody {
312                message: "unauthorized".into(),
313                r#type: "auth_error".into(),
314                details: Some(json!({ "hint": "supply Authorization: Bearer <token>" })),
315            },
316        };
317        roundtrip(&e);
318    }
319
320    #[test]
321    fn unknown_lookup_response_kind_errors_cleanly() {
322        // Future-proof: a wire variant we don't know about should
323        // fail to deserialize rather than silently match the wrong
324        // case. We assert by attempting to parse a clearly-novel
325        // shape and expecting an error.
326        let raw = json!({ "kind": "future_variant", "payload": "x" });
327        let err = serde_json::from_value::<LookupResponse>(raw).unwrap_err();
328        assert!(err.to_string().contains("future_variant"));
329    }
330}