Skip to main content

contextgraph_types/
query.rs

1//! `context/query` request/response shapes
2//! (`SPEC.md` §5). Budget-aware
3//! by contract: every query carries `max_tokens`; a conforming provider
4//! never returns more than the budget and never lies about cost.
5
6use serde::{Deserialize, Serialize};
7
8use crate::attest::{FrameAttestation, ProvenanceAttestation};
9use crate::frame::{ContextFrame, FrameKind, Representation};
10use crate::identity::FrameId;
11
12/// A request to a CGP provider for context frames relevant to a goal.
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct ContextQuery {
15    /// The task/turn goal driving retrieval.
16    pub goal: String,
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub query_text: Option<String>,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub embedding: Option<Vec<f32>>,
21    #[serde(default, skip_serializing_if = "Vec::is_empty")]
22    pub kinds: Vec<FrameKind>,
23    /// Anchor URIs (open files, mentioned symbols) used for graph-proximity
24    /// scoring.
25    #[serde(default, skip_serializing_if = "Vec::is_empty")]
26    pub anchors: Vec<String>,
27    pub max_frames: u32,
28    pub max_tokens: u32,
29    /// Pin retrieval to a point in time for bi-temporal facts.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub as_of: Option<String>,
32    /// Ordered [frame representation](Representation) preference. The provider
33    /// returns the first supported representation it can satisfy. Empty on the
34    /// wire ⇒ the default `[full]`, so pre-representation hosts are unchanged.
35    #[serde(default, skip_serializing_if = "Vec::is_empty")]
36    pub representation_preferences: Vec<Representation>,
37}
38
39impl ContextQuery {
40    /// The effective ordered representation preference, defaulting to `[full]`
41    /// when the host stated none (the legacy behavior).
42    pub fn preferred_representations(&self) -> Vec<Representation> {
43        if self.representation_preferences.is_empty() {
44            vec![Representation::Full]
45        } else {
46            self.representation_preferences.clone()
47        }
48    }
49
50    /// The representation a provider should return: the first
51    /// [preferred](Self::preferred_representations) one it supports. `None` ⇒
52    /// none of the requested representations is supported and the provider must
53    /// answer `unsupported_representation`.
54    pub fn select_representation(&self, supported: &[Representation]) -> Option<Representation> {
55        self.preferred_representations()
56            .into_iter()
57            .find(|wanted| supported.contains(wanted))
58    }
59}
60
61/// The response to a `context/query` call.
62///
63/// # Where an attestation rides
64///
65/// [`frame_attestations`](Self::frame_attestations) and
66/// [`result_attestation`](Self::result_attestation) are the wire home of
67/// `SPEC.md` §6.5's evidence (§6.5.5, F11–F13). They sit on the *result* rather
68/// than on the `frames` envelope because an attestation is a property of the
69/// answer, exactly like `truncated`: the envelope carries only what the
70/// transport needs (`type`, the correlation `id`), and an in-process provider
71/// that returns a `ContextQueryResult` with no envelope at all must still be
72/// able to sign what it serves.
73///
74/// Both are optional and both are omitted when empty, so an unsigned answer is
75/// byte-identical to one from a provider written before this existed.
76#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
77pub struct ContextQueryResult {
78    pub frames: Vec<ContextFrame>,
79    /// True if the provider had more candidates than fit the budget.
80    pub truncated: bool,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub dropped_estimate: Option<u32>,
83    /// Detached per-frame evidence, one entry per attested frame
84    /// (`SPEC.md` §6.5.5). Never a parallel array: each entry names the
85    /// [`FrameId`] it covers in full.
86    #[serde(default, skip_serializing_if = "Vec::is_empty")]
87    pub frame_attestations: Vec<FrameAttestation>,
88    /// One signature over the whole answer: a detached attestation whose
89    /// `signed_commitment` is the §6.5.3 Merkle root over the commitments of
90    /// exactly the frames in [`frames`](Self::frames), in canonical order.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub result_attestation: Option<ProvenanceAttestation>,
93}
94
95impl ContextQueryResult {
96    /// An answer carrying no detached evidence — what every provider that does
97    /// not sign returns.
98    ///
99    /// It exists so that adding [`frame_attestations`](Self::frame_attestations)
100    /// and [`result_attestation`](Self::result_attestation) to this struct is
101    /// not a rewrite for a caller that never signs anything. A struct literal
102    /// naming the first three fields stopped compiling when those two landed;
103    /// this constructor, and `..Default::default()` on a literal, are the two
104    /// ways to keep such a caller to a one-line change.
105    pub fn unattested(
106        frames: Vec<ContextFrame>,
107        truncated: bool,
108        dropped_estimate: Option<u32>,
109    ) -> Self {
110        Self {
111            frames,
112            truncated,
113            dropped_estimate,
114            frame_attestations: Vec::new(),
115            result_attestation: None,
116        }
117    }
118
119    /// Sum of `token_cost` across returned frames — must never exceed the
120    /// query's `max_tokens` for a conforming provider (checked in
121    /// `contextgraph-conformance`, phase 3; this is the cheap client-side sanity
122    /// check any host can run today).
123    pub fn total_token_cost(&self) -> u64 {
124        self.frames.iter().map(|f| f.token_cost as u64).sum()
125    }
126
127    pub fn respects_budget(&self, max_tokens: u32) -> bool {
128        self.total_token_cost() <= max_tokens as u64
129    }
130
131    /// Whether the provider honored the query's `max_frames` cap
132    /// (`SPEC.md` §B4).
133    ///
134    /// `max_frames` was part of the query contract from the beginning and was
135    /// audited by nothing: a provider returning ten thousand one-token frames
136    /// against `max_frames: 8` passed every check. Frame count is a real cost
137    /// — each frame carries a title, a citation label, and rendering chrome the
138    /// token budget does not capture.
139    pub fn respects_frame_limit(&self, max_frames: u32) -> bool {
140        self.frames.len() as u64 <= max_frames as u64
141    }
142
143    /// Frames whose declared `token_cost` does not match the canonical count
144    /// for their content (`SPEC.md` §B3).
145    ///
146    /// Returns ids so a host's audit report can name the offending frames
147    /// rather than only the provider.
148    pub fn frames_with_dishonest_cost(&self) -> Vec<&str> {
149        self.frames
150            .iter()
151            .filter(|f| !f.declares_honest_token_cost())
152            .map(|f| f.id.as_str())
153            .collect()
154    }
155
156    /// The sum of the *canonical* costs of the returned frames — what the
157    /// provider's frames actually cost, as opposed to what it claimed.
158    pub fn canonical_token_cost(&self) -> u64 {
159        self.frames
160            .iter()
161            .map(|f| f.expected_inline_token_cost() as u64)
162            .sum()
163    }
164
165    /// Whether this answer carries any detached evidence at all
166    /// (`SPEC.md` §6.5.5).
167    pub fn is_attested(&self) -> bool {
168        self.result_attestation.is_some()
169            || self.frame_attestations.iter().any(|a| a.carries_evidence())
170    }
171
172    /// The attestation entry covering one frame identity, if the provider sent
173    /// one.
174    ///
175    /// Matching is on the whole `(provider_id, frame_id, content_digest)`
176    /// triple, never on the frame id alone: two frames sharing an id but not a
177    /// digest are different bytes, and handing the first one's evidence to the
178    /// second is the substitution the identity binding exists to prevent
179    /// (`SPEC.md` §6.5.2).
180    pub fn attestation_for(&self, frame: &FrameId) -> Option<&FrameAttestation> {
181        self.frame_attestations.iter().find(|a| &a.frame == frame)
182    }
183
184    /// Attestation entries naming a frame this result does not carry
185    /// (`SPEC.md` §6.5.5, F11).
186    ///
187    /// An entry with no frame beside it is evidence for something the host was
188    /// never shown. It is not merely useless: a host that counted entries
189    /// rather than matching them would report an answer as more thoroughly
190    /// attested than it is. Returned as identities so a report can name them.
191    pub fn orphaned_attestations(&self, provider_id: &str) -> Vec<&FrameId> {
192        let present: Vec<FrameId> = self
193            .frames
194            .iter()
195            .map(|frame| frame.identity(provider_id))
196            .collect();
197        self.frame_attestations
198            .iter()
199            .map(|entry| &entry.frame)
200            .filter(|id| !present.contains(id))
201            .collect()
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::attest::{InclusionProof, InclusionStep};
209    use crate::frame::ContextFrame;
210
211    fn frame_with_cost(id: &str, cost: u32) -> ContextFrame {
212        ContextFrame::full(id, FrameKind::Snippet, id, String::new(), 0.5, cost)
213    }
214
215    #[test]
216    fn context_query_roundtrips() {
217        let query = ContextQuery {
218            goal: "fix the failing test".into(),
219            query_text: Some("failing test".into()),
220            embedding: None,
221            kinds: vec![FrameKind::Symbol, FrameKind::Doc],
222            anchors: vec!["file:///repo/src/lib.rs".into()],
223            max_frames: 20,
224            max_tokens: 4000,
225            as_of: None,
226            representation_preferences: vec![],
227        };
228        let json = serde_json::to_string(&query).unwrap();
229        let back: ContextQuery = serde_json::from_str(&json).unwrap();
230        assert_eq!(back, query);
231    }
232
233    #[test]
234    fn representation_preferences_default_to_full_and_select_first_supported() {
235        // A host that states nothing gets the legacy `[full]` behavior, and the
236        // field is omitted from the wire.
237        let mut query = ContextQuery {
238            goal: "g".into(),
239            query_text: None,
240            embedding: None,
241            kinds: vec![],
242            anchors: vec![],
243            max_frames: 1,
244            max_tokens: 10,
245            as_of: None,
246            representation_preferences: vec![],
247        };
248        assert_eq!(
249            query.preferred_representations(),
250            vec![Representation::Full]
251        );
252        assert!(
253            !serde_json::to_string(&query)
254                .unwrap()
255                .contains("representation_preferences")
256        );
257        assert_eq!(
258            query.select_representation(&[Representation::Full]),
259            Some(Representation::Full)
260        );
261
262        // With an explicit preference, the provider returns the first it can
263        // satisfy; if none is supported, it must answer unsupported.
264        query.representation_preferences = vec![Representation::Reference, Representation::Full];
265        assert_eq!(
266            query.select_representation(&[Representation::Full]),
267            Some(Representation::Full),
268        );
269        assert_eq!(
270            query.select_representation(&[Representation::Reference, Representation::Full]),
271            Some(Representation::Reference),
272        );
273        assert_eq!(
274            query.select_representation(&[Representation::Compact]),
275            None
276        );
277    }
278
279    #[test]
280    fn respects_budget_true_when_under_or_at_limit() {
281        let result = ContextQueryResult {
282            frames: vec![frame_with_cost("a", 100), frame_with_cost("b", 200)],
283            truncated: false,
284            dropped_estimate: None,
285            ..Default::default()
286        };
287        assert_eq!(result.total_token_cost(), 300);
288        assert!(result.respects_budget(300));
289        assert!(result.respects_budget(500));
290    }
291
292    #[test]
293    fn respects_budget_false_when_provider_lies_about_cost() {
294        let result = ContextQueryResult {
295            frames: vec![frame_with_cost("a", 400)],
296            truncated: false,
297            dropped_estimate: None,
298            ..Default::default()
299        };
300        assert!(!result.respects_budget(300));
301    }
302
303    /// A frame whose declared cost is the canonical cost of its content.
304    fn honest_frame(id: &str, content: &str) -> ContextFrame {
305        let mut frame = frame_with_cost(id, 0);
306        frame.content = Some(content.to_string());
307        frame.token_cost = frame.expected_inline_token_cost();
308        frame
309    }
310
311    #[test]
312    fn frame_limit_catches_the_provider_that_floods_with_cheap_frames() {
313        // The exact hole from issue #10: ten thousand one-token frames against
314        // `max_frames: 8` used to pass everything, because only the token
315        // budget was audited.
316        let flood = ContextQueryResult {
317            frames: (0..50)
318                .map(|i| honest_frame(&format!("f{i}"), "x"))
319                .collect(),
320            truncated: false,
321            dropped_estimate: None,
322            ..Default::default()
323        };
324        assert!(flood.respects_budget(10_000), "the token budget is fine");
325        assert!(!flood.respects_frame_limit(8), "but the frame cap is not");
326        assert!(flood.respects_frame_limit(50), "boundary is inclusive");
327    }
328
329    #[test]
330    fn an_honest_result_reports_no_dishonest_frames() {
331        let result = ContextQueryResult {
332            frames: vec![honest_frame("a", "abcd"), honest_frame("b", "abcdefgh")],
333            truncated: false,
334            dropped_estimate: None,
335            ..Default::default()
336        };
337        assert!(result.frames_with_dishonest_cost().is_empty());
338        assert_eq!(result.total_token_cost(), result.canonical_token_cost());
339    }
340
341    #[test]
342    fn dishonest_frames_are_named_and_the_true_cost_is_recoverable() {
343        let mut liar = honest_frame("liar", &"x".repeat(4_000));
344        liar.token_cost = 1; // claims 1, actually costs 1_000
345        let result = ContextQueryResult {
346            frames: vec![honest_frame("honest", "abcd"), liar],
347            truncated: false,
348            dropped_estimate: None,
349            ..Default::default()
350        };
351
352        assert_eq!(result.frames_with_dishonest_cost(), vec!["liar"]);
353        // The declared sum sails under a budget the real content blows past.
354        assert_eq!(result.total_token_cost(), 2);
355        assert_eq!(result.canonical_token_cost(), 1_001);
356        assert!(result.respects_budget(100));
357    }
358
359    // -----------------------------------------------------------------------
360    // Attestations on the wire (`SPEC.md` §6.5.5, F11–F13; ADR 0014)
361    // -----------------------------------------------------------------------
362
363    fn sample_attestation(commitment: &str) -> ProvenanceAttestation {
364        ProvenanceAttestation::new(
365            commitment,
366            "key-1",
367            crate::ALGORITHM_ED25519,
368            "example-provider",
369            "ab".repeat(64),
370            "2026-08-29T00:00:00Z",
371        )
372    }
373
374    fn attested_result() -> ContextQueryResult {
375        let mut frame = frame_with_cost("frame:a", 4);
376        frame.content_digest = Some(format!("sha256:{}", "11".repeat(32)));
377        let identity = frame.identity("example-provider");
378        ContextQueryResult {
379            frames: vec![frame],
380            truncated: false,
381            dropped_estimate: None,
382            frame_attestations: vec![
383                FrameAttestation::signed(
384                    identity,
385                    sample_attestation(&format!("sha256:{}", "22".repeat(32))),
386                )
387                .with_inclusion_proof(InclusionProof {
388                    leaf_index: 0,
389                    leaf_count: 1,
390                    path: vec![],
391                }),
392            ],
393            result_attestation: Some(sample_attestation(&format!("sha256:{}", "33".repeat(32)))),
394        }
395    }
396
397    #[test]
398    fn an_attested_result_round_trips_byte_for_byte() {
399        // The whole point of putting the attestation on the result: it has to
400        // survive the trip. A shape that serializes but does not come back is
401        // evidence a host cannot store.
402        let result = attested_result();
403        let json = serde_json::to_string(&result).unwrap();
404        let back: ContextQueryResult = serde_json::from_str(&json).unwrap();
405        assert_eq!(back, result);
406        assert_eq!(serde_json::to_string(&back).unwrap(), json);
407        assert!(result.is_attested());
408    }
409
410    #[test]
411    fn the_attestation_never_travels_inside_the_frame_it_covers() {
412        // F6/F11. Detachment is the reason re-signing and key rotation cannot
413        // perturb a frame's content-addressed identity, so it is checked on the
414        // serialized bytes rather than trusted to the struct layout.
415        let result = attested_result();
416        let value = serde_json::to_value(&result).unwrap();
417        let frame = &value["frames"][0];
418        for member in ["attestation", "frame_attestations", "result_attestation"] {
419            assert!(
420                frame.get(member).is_none(),
421                "a frame must carry no attestation member, found `{member}`: {frame}"
422            );
423        }
424        assert!(value.get("frame_attestations").is_some());
425        assert!(value.get("result_attestation").is_some());
426    }
427
428    #[test]
429    fn an_unsigned_answer_is_byte_identical_to_one_from_a_provider_that_predates_this() {
430        // Additive within contextgraph/1: a provider that signs nothing must
431        // emit exactly the bytes it emitted before these members existed, or
432        // every existing golden fixture and cache key moves.
433        let result = ContextQueryResult {
434            frames: vec![frame_with_cost("a", 1)],
435            truncated: false,
436            dropped_estimate: None,
437            ..Default::default()
438        };
439        let json = serde_json::to_string(&result).unwrap();
440        assert!(!json.contains("frame_attestations"), "{json}");
441        assert!(!json.contains("result_attestation"), "{json}");
442        assert!(!result.is_attested());
443    }
444
445    #[test]
446    fn an_old_consumer_ignoring_the_new_members_still_parses_the_envelope() {
447        // SPEC.md §13 U1 in the direction that matters here: the members are
448        // optional, so a 1.0 peer that drops them still reads a signed answer
449        // as a valid answer.
450        let attested = serde_json::to_value(attested_result()).unwrap();
451        let mut stripped = attested.as_object().unwrap().clone();
452        stripped.remove("frame_attestations");
453        stripped.remove("result_attestation");
454        let back: ContextQueryResult =
455            serde_json::from_value(serde_json::Value::Object(stripped)).unwrap();
456        assert!(!back.is_attested());
457        assert_eq!(back.frames, attested_result().frames);
458    }
459
460    #[test]
461    fn an_entry_is_matched_on_the_whole_identity_triple_not_the_frame_id() {
462        // The substitution §6.5.2's identity binding exists to prevent: two
463        // frames sharing an id but not a digest are different bytes, and one's
464        // evidence must not answer for the other.
465        let result = attested_result();
466        let served = result.frames[0].identity("example-provider");
467        assert!(result.attestation_for(&served).is_some());
468
469        let same_id_other_bytes = FrameId::new(
470            "example-provider",
471            "frame:a",
472            Some(format!("sha256:{}", "99".repeat(32))),
473        );
474        assert!(
475            result.attestation_for(&same_id_other_bytes).is_none(),
476            "different bytes must not inherit another frame's attestation"
477        );
478        let other_provider = FrameId::new(
479            "impostor",
480            "frame:a",
481            result.frames[0].content_digest.clone(),
482        );
483        assert!(result.attestation_for(&other_provider).is_none());
484    }
485
486    #[test]
487    fn an_attestation_for_a_frame_the_host_never_received_is_reported_as_orphaned() {
488        let mut result = attested_result();
489        assert!(result.orphaned_attestations("example-provider").is_empty());
490
491        let ghost = FrameId::new("example-provider", "frame:never-sent", None);
492        result.frame_attestations.push(FrameAttestation::signed(
493            ghost.clone(),
494            sample_attestation("sha256:00"),
495        ));
496        assert_eq!(
497            result.orphaned_attestations("example-provider"),
498            vec![&ghost],
499            "evidence for a frame nobody was shown is not evidence"
500        );
501    }
502
503    #[test]
504    fn an_entry_carrying_neither_a_signature_nor_a_proof_asserts_nothing() {
505        let entry = FrameAttestation {
506            frame: FrameId::new("example-provider", "frame:a", None),
507            attestation: None,
508            inclusion_proof: None,
509        };
510        assert!(!entry.carries_evidence());
511        let result = ContextQueryResult {
512            frames: vec![frame_with_cost("frame:a", 1)],
513            truncated: false,
514            dropped_estimate: None,
515            frame_attestations: vec![entry],
516            result_attestation: None,
517        };
518        assert!(
519            !result.is_attested(),
520            "a bare identity must not read as an attested answer"
521        );
522    }
523
524    #[test]
525    fn a_root_signed_set_needs_no_per_frame_signature() {
526        // The cheapest honest shape: one signature over the root, one proof per
527        // frame, no per-frame signatures. If `attestation` were required this
528        // would be unrepresentable and a provider would sign n times to say
529        // what one signature says.
530        let entry = FrameAttestation::proven(
531            FrameId::new("example-provider", "frame:a", None),
532            InclusionProof {
533                leaf_index: 0,
534                leaf_count: 2,
535                path: vec![InclusionStep {
536                    sibling: format!("sha256:{}", "44".repeat(32)),
537                    sibling_is_left: false,
538                }],
539            },
540        );
541        assert!(entry.carries_evidence());
542        let json = serde_json::to_string(&entry).unwrap();
543        assert!(
544            !json.contains("\"attestation\""),
545            "an absent per-frame signature must be omitted, not null: {json}"
546        );
547        let back: FrameAttestation = serde_json::from_str(&json).unwrap();
548        assert_eq!(back, entry);
549    }
550}