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}