contextgraph_types/verify.rs
1//! `context/verify` — pull-based revalidation of frames a host already holds
2//! (`docs/context-reuse.md` §4).
3//!
4//! Deterministic composition (§1) makes reusing context *cheap*: an unchanged
5//! frame set renders byte-identically and rides the provider's prompt cache.
6//! But cheap reuse is only safe if the frames are still **true**. Without a way
7//! to ask, a host faces a bad pair of choices at every turn boundary: re-query
8//! everything (paying tokens and latency, and destroying the very prefix
9//! stability §1 bought) or reuse silently and risk citing evidence that changed
10//! underneath it.
11//!
12//! A verify exchange is the cheap third option. The host sends a batch of
13//! [`FrameId`]s — `(provider id, frame id, content digest)` — and the provider
14//! answers one [`Verdict`] per frame. **No frame body travels in either
15//! direction.** That is the whole economic point: verification costs *bytes*,
16//! not *tokens*, so a host can afford to do it every turn on frames it would
17//! otherwise have re-queried in full.
18//!
19//! The digest is the ground truth. A provider compares the digest the host
20//! presents against the digest its source has *now*: equal ⇒ [`Valid`](Verdict::Valid),
21//! different ⇒ [`Stale`](Verdict::Stale), source gone ⇒ [`Gone`](Verdict::Gone),
22//! can't tell ⇒ [`Unknown`](Verdict::Unknown). Because the digest is
23//! provider-declared and opaque (§1), the provider is the only party that can
24//! answer — which is exactly why §4's conformance case exists to hold it honest.
25//!
26//! **Verify is the pull counterpart to subscribe (#6), not a replacement.** A
27//! provider that can watch its sources pushes invalidations; one that cannot —
28//! a stateless HTTP endpoint, a batch-rebuilt index — can still answer a
29//! question asked of it. Both are capability-gated, and a host that has neither
30//! falls back to re-querying.
31
32use serde::{Deserialize, Serialize};
33
34use crate::identity::FrameId;
35
36/// A host's request to revalidate frames it already holds
37/// (`docs/context-reuse.md` §4).
38///
39/// Carries identities only — never frame bodies. Every identity in one request
40/// belongs to the provider it is sent to; a host holding frames from several
41/// providers sends one request each.
42#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
43pub struct VerifyRequest {
44 /// The frame identities to revalidate. A host **SHOULD** only include
45 /// identities that carry a digest ([`FrameId::is_verifiable`]) — a
46 /// digest-less frame cannot be revalidated and is re-queried instead.
47 #[serde(default)]
48 pub frames: Vec<FrameId>,
49}
50
51impl VerifyRequest {
52 /// A request for the given identities.
53 pub fn new(frames: Vec<FrameId>) -> Self {
54 Self { frames }
55 }
56
57 /// How many identities this request asks about.
58 pub fn len(&self) -> usize {
59 self.frames.len()
60 }
61
62 pub fn is_empty(&self) -> bool {
63 self.frames.is_empty()
64 }
65}
66
67/// A provider's answer for one frame (`docs/context-reuse.md` §4).
68///
69/// Serializes as an internally-tagged object keyed on `status`, so a verdict is
70/// self-describing on the wire and gains variants without breaking parsers:
71/// `{"status": "valid"}`, `{"status": "stale", "replacement_digest": "sha256:…"}`,
72/// `{"status": "gone"}`, `{"status": "unknown"}`.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(tag = "status", rename_all = "snake_case")]
75pub enum Verdict {
76 /// The frame's content is unchanged: the digest the host presented matches
77 /// the provider's current digest for that frame. The host **MAY** keep
78 /// reusing the body it already holds.
79 Valid,
80 /// The frame still exists but its content changed — the presented digest no
81 /// longer matches. The host **MUST NOT** keep serving the body it holds.
82 ///
83 /// `replacement_digest` is the provider's *current* digest for the frame,
84 /// offered so a host can tell "changed again since I last looked" from
85 /// "changed to something I already fetched" without a round trip. It is
86 /// optional: a provider that knows the content differs but not what it is
87 /// now still answers `stale` honestly. **It is a digest, never a body** —
88 /// the host re-queries if it wants the new content.
89 Stale {
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 replacement_digest: Option<String>,
92 },
93 /// The frame no longer exists — the source was deleted, or the provider no
94 /// longer serves it. The host **MUST** drop it, and re-querying *this
95 /// identity* is pointless.
96 Gone,
97 /// The provider cannot say. It may never have served this frame, may have
98 /// lost the history needed to compare digests (a rebuilt index), or may not
99 /// recognize the identity. The host **MUST NOT** treat this as validity —
100 /// an unverifiable frame is re-queried, never reused on a shrug.
101 Unknown,
102}
103
104impl Verdict {
105 /// Whether a host may keep reusing the frame body it already holds. **Only**
106 /// [`Valid`](Self::Valid) — reuse requires a positive answer, never the
107 /// absence of a negative one (`docs/context-reuse.md` §4, requirement V2).
108 pub fn permits_reuse(&self) -> bool {
109 matches!(self, Self::Valid)
110 }
111
112 /// Whether re-querying the provider could recover usable content. True for
113 /// [`Stale`](Self::Stale) (content exists, it changed) and
114 /// [`Unknown`](Self::Unknown) (the provider couldn't say, so ask properly).
115 /// False for [`Gone`](Self::Gone) — the frame is not there to re-fetch — and
116 /// for [`Valid`](Self::Valid), which needs no re-query at all.
117 pub fn warrants_requery(&self) -> bool {
118 matches!(self, Self::Stale { .. } | Self::Unknown)
119 }
120
121 /// The verdict's wire name, for reports and log lines.
122 pub fn status(&self) -> &'static str {
123 match self {
124 Self::Valid => "valid",
125 Self::Stale { .. } => "stale",
126 Self::Gone => "gone",
127 Self::Unknown => "unknown",
128 }
129 }
130}
131
132/// One frame's verdict, paired with the identity it answers
133/// (`docs/context-reuse.md` §4).
134///
135/// The identity is **echoed in full** rather than implied by position: a host
136/// correlates verdicts by matching `frame`, so a provider that reorders,
137/// omits, or duplicates entries can't silently shift a `valid` onto the wrong
138/// frame. Entries a host didn't ask about are ignored; identities that come
139/// back with no entry are treated as [`Unknown`](Verdict::Unknown).
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141pub struct FrameVerdict {
142 /// The identity being answered — echoed from the request.
143 pub frame: FrameId,
144 /// The provider's answer for it.
145 #[serde(flatten)]
146 pub verdict: Verdict,
147}
148
149impl FrameVerdict {
150 /// Pair an identity with its verdict.
151 pub fn new(frame: FrameId, verdict: Verdict) -> Self {
152 Self { frame, verdict }
153 }
154}
155
156/// A provider's answer to a [`VerifyRequest`] (`docs/context-reuse.md` §4).
157#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
158pub struct VerifyResponse {
159 /// One verdict per frame the provider is answering for.
160 #[serde(default)]
161 pub verdicts: Vec<FrameVerdict>,
162}
163
164impl VerifyResponse {
165 /// A response carrying the given verdicts.
166 pub fn new(verdicts: Vec<FrameVerdict>) -> Self {
167 Self { verdicts }
168 }
169
170 /// Answer every requested identity with the same verdict — the shape a
171 /// provider without real verification support returns
172 /// ([`Unknown`](Verdict::Unknown)), and a convenience for tests.
173 pub fn uniform(request: &VerifyRequest, verdict: Verdict) -> Self {
174 Self {
175 verdicts: request
176 .frames
177 .iter()
178 .map(|frame| FrameVerdict::new(frame.clone(), verdict.clone()))
179 .collect(),
180 }
181 }
182
183 /// The verdict for one identity, matched on the **full** identity rather
184 /// than position. `None` when the provider returned no entry for it — which
185 /// a host treats as [`Unknown`](Verdict::Unknown).
186 pub fn verdict_for(&self, frame: &FrameId) -> Option<&Verdict> {
187 self.verdicts
188 .iter()
189 .find(|entry| &entry.frame == frame)
190 .map(|entry| &entry.verdict)
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 fn id(frame: &str, digest: Option<&str>) -> FrameId {
199 FrameId::new("repo-graph", frame, digest.map(String::from))
200 }
201
202 #[test]
203 fn a_request_carries_identities_and_no_frame_bodies() {
204 let request = VerifyRequest::new(vec![
205 id("retry-doc", Some("sha256:9f2c")),
206 id("timeout-doc", Some("sha256:aa01")),
207 ]);
208 let json = serde_json::to_string(&request).unwrap();
209 // The economic guarantee of §4: verification costs bytes, not tokens.
210 assert!(
211 !json.contains("content\"") && !json.contains("title"),
212 "a verify request must never carry frame bodies: {json}"
213 );
214 let back: VerifyRequest = serde_json::from_str(&json).unwrap();
215 assert_eq!(back, request);
216 assert_eq!(back.len(), 2);
217 }
218
219 #[test]
220 fn every_verdict_round_trips_through_its_tagged_wire_form() {
221 for (verdict, status) in [
222 (Verdict::Valid, "valid"),
223 (
224 Verdict::Stale {
225 replacement_digest: None,
226 },
227 "stale",
228 ),
229 (
230 Verdict::Stale {
231 replacement_digest: Some("sha256:beef".into()),
232 },
233 "stale",
234 ),
235 (Verdict::Gone, "gone"),
236 (Verdict::Unknown, "unknown"),
237 ] {
238 let json = serde_json::to_string(&verdict).unwrap();
239 assert!(
240 json.contains(&format!("\"status\":\"{status}\"")),
241 "verdict must be tagged on `status`: {json}"
242 );
243 assert_eq!(verdict.status(), status);
244 let back: Verdict = serde_json::from_str(&json).unwrap();
245 assert_eq!(back, verdict);
246 }
247 }
248
249 #[test]
250 fn a_stale_verdict_without_a_replacement_omits_the_field() {
251 let json = serde_json::to_string(&Verdict::Stale {
252 replacement_digest: None,
253 })
254 .unwrap();
255 assert!(
256 !json.contains("replacement_digest"),
257 "an absent replacement must be omitted, not null: {json}"
258 );
259 // A provider that knows content changed but not what it is now is still
260 // answering honestly.
261 let back: Verdict = serde_json::from_str(&json).unwrap();
262 assert!(!back.permits_reuse());
263 assert!(back.warrants_requery());
264 }
265
266 #[test]
267 fn a_stale_verdict_carries_a_replacement_digest_but_never_a_body() {
268 let verdict = Verdict::Stale {
269 replacement_digest: Some("sha256:beef".into()),
270 };
271 let json = serde_json::to_string(&verdict).unwrap();
272 assert!(json.contains("sha256:beef"));
273 let back: Verdict = serde_json::from_str(&json).unwrap();
274 assert_eq!(back, verdict);
275 }
276
277 #[test]
278 fn only_valid_permits_reuse() {
279 // The default-deny rule (V2): reuse needs a positive answer, never the
280 // mere absence of a negative one.
281 assert!(Verdict::Valid.permits_reuse());
282 for verdict in [
283 Verdict::Stale {
284 replacement_digest: None,
285 },
286 Verdict::Gone,
287 Verdict::Unknown,
288 ] {
289 assert!(
290 !verdict.permits_reuse(),
291 "{} must not permit reuse",
292 verdict.status()
293 );
294 }
295 }
296
297 #[test]
298 fn gone_is_the_one_verdict_that_does_not_warrant_a_requery() {
299 // Nothing to re-fetch: the frame is not there anymore.
300 assert!(!Verdict::Gone.warrants_requery());
301 assert!(
302 Verdict::Stale {
303 replacement_digest: None
304 }
305 .warrants_requery()
306 );
307 assert!(Verdict::Unknown.warrants_requery());
308 // A valid frame needs no re-query — that is the whole point.
309 assert!(!Verdict::Valid.warrants_requery());
310 }
311
312 #[test]
313 fn a_response_round_trips_and_flattens_the_verdict_into_each_entry() {
314 let response = VerifyResponse::new(vec![
315 FrameVerdict::new(id("retry-doc", Some("sha256:9f2c")), Verdict::Valid),
316 FrameVerdict::new(
317 id("timeout-doc", Some("sha256:aa01")),
318 Verdict::Stale {
319 replacement_digest: Some("sha256:bb02".into()),
320 },
321 ),
322 ]);
323 let json = serde_json::to_string(&response).unwrap();
324 // `verdict` is flattened, so an entry reads as one flat object.
325 assert!(
326 !json.contains("\"verdict\""),
327 "verdict must flatten: {json}"
328 );
329 let back: VerifyResponse = serde_json::from_str(&json).unwrap();
330 assert_eq!(back, response);
331 }
332
333 #[test]
334 fn a_verdict_is_correlated_by_full_identity_not_by_position() {
335 // A provider that reorders its answers must not shift a `valid` onto
336 // the wrong frame.
337 let valid_frame = id("retry-doc", Some("sha256:9f2c"));
338 let stale_frame = id("timeout-doc", Some("sha256:aa01"));
339 let response = VerifyResponse::new(vec![
340 FrameVerdict::new(stale_frame.clone(), Verdict::Gone),
341 FrameVerdict::new(valid_frame.clone(), Verdict::Valid),
342 ]);
343 assert_eq!(response.verdict_for(&valid_frame), Some(&Verdict::Valid));
344 assert_eq!(response.verdict_for(&stale_frame), Some(&Verdict::Gone));
345 }
346
347 #[test]
348 fn a_verdict_for_a_different_digest_does_not_match_the_asked_identity() {
349 // The digest is part of the identity: an answer about other bytes is an
350 // answer about a different question.
351 let asked = id("retry-doc", Some("sha256:9f2c"));
352 let response = VerifyResponse::new(vec![FrameVerdict::new(
353 id("retry-doc", Some("sha256:0000")),
354 Verdict::Valid,
355 )]);
356 assert_eq!(
357 response.verdict_for(&asked),
358 None,
359 "a verdict about a different digest must not answer for this one"
360 );
361 }
362
363 #[test]
364 fn a_uniform_response_answers_every_requested_identity() {
365 // The shape a provider without verification support returns.
366 let request = VerifyRequest::new(vec![
367 id("retry-doc", Some("sha256:9f2c")),
368 id("timeout-doc", Some("sha256:aa01")),
369 ]);
370 let response = VerifyResponse::uniform(&request, Verdict::Unknown);
371 assert_eq!(response.verdicts.len(), 2);
372 for frame in &request.frames {
373 assert_eq!(response.verdict_for(frame), Some(&Verdict::Unknown));
374 }
375 }
376}