Skip to main content

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}