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