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}