Skip to main content

contextgraph_types/
capability.rs

1//! Handshake and capability negotiation types
2//! (`SPEC.md` §3). `DataFlow` is
3//! the security-critical field: hosts surface it at install/consent time,
4//! and `egress: true` providers must never be auto-enabled (SPEC.md §4).
5
6use serde::{Deserialize, Serialize};
7
8use crate::frame::Representation;
9use crate::scope::EgressScope;
10
11/// Declares what a provider does with data, so a host can gate consent
12/// before ever sending it a query.
13///
14/// Not `Copy`: [`egress_scopes`](Self::egress_scopes) is an owned `Vec`, so a
15/// `DataFlow` is cloned, not bit-copied.
16#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
17pub struct DataFlow {
18    /// Can see workspace content via query payloads.
19    #[serde(default)]
20    pub reads: bool,
21    /// The provider durably persists data derived from what it receives —
22    /// indexing query payloads, retaining request logs, and the like.
23    ///
24    /// This is a **consent-surface declaration**, not a capability flag: it
25    /// does not imply any host-callable write method, and none exists (see
26    /// [ADR 0004](../../docs/adr/0004-dead-capability-surface.md)). It is kept
27    /// because "you may read my workspace" and "you may durably record things
28    /// about me" are different grants, and a user deserves to be told about
29    /// the second one.
30    #[serde(default)]
31    pub writes: bool,
32    /// Sends anything off the local machine. A host MUST require explicit,
33    /// one-time consent before enabling a provider with `egress: true`.
34    #[serde(default)]
35    pub egress: bool,
36    /// The [egress scopes](EgressScope) this provider's served content falls
37    /// under (`docs/context-reuse.md` §3). Empty ⇒ the provider declares only
38    /// the boolean `egress` posture (the pre-scope contract). An off-machine
39    /// scope here is only consistent with `egress == true`
40    /// (see [`scopes_consistent`](Self::scopes_consistent)); a scope governs
41    /// every frame the provider serves.
42    #[serde(default, skip_serializing_if = "Vec::is_empty")]
43    pub egress_scopes: Vec<EgressScope>,
44}
45
46impl DataFlow {
47    /// The declared scopes whose content leaves the machine
48    /// ([`EgressScope::is_off_machine`]).
49    pub fn off_machine_scopes(&self) -> impl Iterator<Item = &EgressScope> {
50        self.egress_scopes.iter().filter(|s| s.is_off_machine())
51    }
52
53    /// Whether the declared scopes are truthful and well-formed
54    /// (`docs/context-reuse.md` §3, requirement C5). A host holds a provider to
55    /// this at the handshake:
56    ///
57    /// - every declared scope MUST be well-formed ([`EgressScope::is_valid`] —
58    ///   custom scopes must be namespaced);
59    /// - an **off-machine scope alongside `egress: false` is a lie** — a
60    ///   provider cannot claim `local-only` posture while declaring content
61    ///   leaves. (The converse is allowed: `egress: true` with no scopes is the
62    ///   legacy boolean contract.)
63    pub fn scopes_consistent(&self) -> bool {
64        if !self.egress_scopes.iter().all(EgressScope::is_valid) {
65            return false;
66        }
67        // An off-machine scope requires the egress bit set.
68        self.egress || self.off_machine_scopes().next().is_none()
69    }
70}
71
72/// Provider identity reported at `initialize`.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct ProviderInfo {
75    pub name: String,
76    pub version: String,
77    pub data_flow: DataFlow,
78}
79
80/// What a provider can do, negotiated at handshake time.
81///
82/// Every field here is a capability a host can actually exercise. `upsert` and
83/// `subscribe` were removed in the pre-freeze sweep because neither had a wire
84/// method, a host API, a schema entry, or a conformance check — a provider
85/// could declare a capability no host on earth could use. See
86/// [ADR 0004](../../docs/adr/0004-dead-capability-surface.md), and the design
87/// sketches under `docs/sketches/` that keep both doors open for a 1.x
88/// additive minor.
89///
90/// Unknown fields are ignored on deserialization, so a provider still emitting
91/// the removed flags handshakes successfully — the removal breaks the Rust API,
92/// not the wire.
93#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
94pub struct Capabilities {
95    #[serde(default)]
96    pub query: QueryCapability,
97    /// The provider echoes the `id` of a request on its reply, so a host may
98    /// pipeline concurrent exchanges over one connection (`SPEC.md` §H4).
99    ///
100    /// Negotiated explicitly rather than discovered by observation. A host that
101    /// sent an `id` speculatively could not tell a provider that does not
102    /// implement correlation from one that implements it incorrectly, which
103    /// makes the guarantee uncheckable — and an uncheckable guarantee is the
104    /// thing this protocol exists to avoid. A provider that does not declare it
105    /// is queried in lock-step and stays fully conformant.
106    #[serde(default)]
107    pub correlation: bool,
108    /// The provider serves [`FrameKind::Graph`](crate::FrameKind::Graph) frames
109    /// and populates [`Relation`](crate::Relation) edges. Gates the graph
110    /// conformance checks (`SPEC.md` §G1–G3).
111    #[serde(default)]
112    pub graph: bool,
113    /// Identifies the embedding space this provider indexes, so a host never
114    /// sends it a vector from a different model.
115    ///
116    /// Format: `<model-id>/<dimensions>[/<normalization>]`, e.g.
117    /// `bge-small-en-v1.5/384/l2`. Matching is exact — see
118    /// [`embedding_fingerprints_match`](crate::embedding_fingerprints_match)
119    /// and `SPEC.md` §E1.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub embeddings_fingerprint: Option<String>,
122    /// Whether this provider answers `context/verify` — pull-based
123    /// revalidation of frames a host already holds (`docs/context-reuse.md`
124    /// §4). Defaults to `false`, so a provider that says nothing is treated as
125    /// not supporting it and the host falls back to re-querying.
126    #[serde(default)]
127    pub verify: bool,
128    /// The [frame representations](Representation) this provider can return
129    /// (build prompt §"Capability negotiation"). Empty ⇒ `full` only, the
130    /// legacy default, so a provider that says nothing keeps working.
131    #[serde(default, skip_serializing_if = "Vec::is_empty")]
132    pub representations: Vec<Representation>,
133    /// Whether this provider answers `context/resolve` for a frame's
134    /// [`content_ref`](crate::ContentRef). Compact/reference support **implies**
135    /// resolve support ([`representations_consistent`](Self::representations_consistent)).
136    #[serde(default)]
137    pub resolve: bool,
138}
139
140impl Capabilities {
141    /// The representations this provider actually offers, defaulting to `[full]`
142    /// when it advertised none (the legacy contract).
143    pub fn offered_representations(&self) -> Vec<Representation> {
144        if self.representations.is_empty() {
145            vec![Representation::Full]
146        } else {
147            self.representations.clone()
148        }
149    }
150
151    /// Whether the advertised representations are honest: `compact`/`reference`
152    /// both hand the host a [`content_ref`](crate::ContentRef) to rehydrate, so
153    /// a provider that cannot [`resolve`](Self::resolve) must not advertise
154    /// them. A provider offering only `full` is always consistent.
155    pub fn representations_consistent(&self) -> bool {
156        if self.resolve {
157            return true;
158        }
159        !self
160            .representations
161            .iter()
162            .any(|rep| matches!(rep, Representation::Compact | Representation::Reference))
163    }
164}
165
166/// The retrieval surface a provider offers.
167#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
168pub struct QueryCapability {
169    /// Frame kinds this provider serves, e.g. `["doc", "snippet"]`.
170    #[serde(default)]
171    pub kinds: Vec<String>,
172}
173
174/// The dimension count declared by an embedding fingerprint, if it is
175/// well-formed.
176///
177/// A fingerprint is `<model-id>/<dimensions>[/<normalization>]`. Returning the
178/// dimension separately is what lets a provider reject a vector whose length
179/// contradicts its own declaration — the cheap check that catches a
180/// misconfigured host before it gets silently garbage similarity scores.
181pub fn fingerprint_dimensions(fingerprint: &str) -> Option<usize> {
182    fingerprint.split('/').nth(1)?.parse().ok()
183}
184
185/// Whether a host may send its embeddings to a provider: exact string equality
186/// of the two fingerprints (`SPEC.md` §E1).
187///
188/// Equality is required rather than, say, matching only the model id, because
189/// dimension and normalization both change what a vector *means*. A host that
190/// sent a 384-dimension unnormalized vector to a provider indexed on 384
191/// L2-normalized vectors would get plausible-looking, meaningless scores —
192/// precisely the class of silent wrongness the protocol exists to make loud.
193pub fn embedding_fingerprints_match(host: &str, provider: &str) -> bool {
194    !host.is_empty() && host == provider
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::scope::EgressScope;
201
202    #[test]
203    fn verify_support_defaults_off() {
204        // A provider that says nothing must not be assumed to verify — the
205        // host falls back to re-querying (`docs/context-reuse.md` §4).
206        let caps = Capabilities::default();
207        assert!(!caps.verify);
208
209        // Absent from the wire ⇒ still false, so pre-§4 providers keep working.
210        // A legacy `subscribe` flag is an ignored unknown field (ADR 0004).
211        let back: Capabilities =
212            serde_json::from_str(r#"{"upsert":false,"subscribe":true}"#).unwrap();
213        assert!(!back.verify);
214
215        let pull: Capabilities = serde_json::from_str(r#"{"verify":true}"#).unwrap();
216        assert!(pull.verify);
217    }
218
219    #[test]
220    fn egress_provider_data_flow_roundtrips() {
221        let flow = DataFlow {
222            reads: true,
223            writes: false,
224            egress: true,
225            egress_scopes: vec![EgressScope::ThirdPartyModel],
226        };
227        let json = serde_json::to_string(&flow).unwrap();
228        let back: DataFlow = serde_json::from_str(&json).unwrap();
229        assert_eq!(back, flow);
230        assert!(
231            back.egress,
232            "egress providers must be inspectable by hosts before consent"
233        );
234    }
235
236    #[test]
237    fn provider_info_defaults_data_flow_to_no_egress() {
238        let flow = DataFlow::default();
239        assert!(
240            !flow.egress,
241            "default DataFlow must never imply egress consent"
242        );
243        assert!(flow.egress_scopes.is_empty());
244        assert!(flow.scopes_consistent());
245    }
246
247    #[test]
248    fn empty_egress_scopes_are_omitted_from_the_wire() {
249        let flow = DataFlow {
250            reads: true,
251            writes: false,
252            egress: false,
253            egress_scopes: vec![],
254        };
255        let json = serde_json::to_string(&flow).unwrap();
256        assert!(
257            !json.contains("egress_scopes"),
258            "an empty scope list must be omitted so the pre-scope wire form is unchanged: {json}"
259        );
260    }
261
262    #[test]
263    fn an_off_machine_scope_with_egress_false_is_inconsistent() {
264        // C5: a provider cannot claim local posture while declaring content
265        // leaves.
266        let lying = DataFlow {
267            reads: true,
268            writes: false,
269            egress: false,
270            egress_scopes: vec![EgressScope::ThirdPartyIndex],
271        };
272        assert!(!lying.scopes_consistent());
273
274        // local-only alongside egress:false is fine.
275        let honest_local = DataFlow {
276            reads: true,
277            writes: false,
278            egress: false,
279            egress_scopes: vec![EgressScope::LocalOnly],
280        };
281        assert!(honest_local.scopes_consistent());
282
283        // An off-machine scope with egress:true is fine.
284        let honest_egress = DataFlow {
285            reads: true,
286            writes: false,
287            egress: true,
288            egress_scopes: vec![EgressScope::ThirdPartyModel],
289        };
290        assert!(honest_egress.scopes_consistent());
291        assert_eq!(honest_egress.off_machine_scopes().count(), 1);
292
293        // A malformed custom scope is inconsistent regardless of egress.
294        let malformed = DataFlow {
295            reads: true,
296            writes: false,
297            egress: true,
298            egress_scopes: vec![EgressScope::Custom("notnamespaced".into())],
299        };
300        assert!(!malformed.scopes_consistent());
301    }
302
303    #[test]
304    fn capabilities_roundtrip_with_defaults() {
305        let caps = Capabilities {
306            query: QueryCapability {
307                kinds: vec!["snippet".into()],
308            },
309            correlation: true,
310            graph: true,
311            embeddings_fingerprint: Some("bge-small-en-v1.5/384/l2".into()),
312            verify: true,
313            representations: vec![Representation::Full, Representation::Reference],
314            resolve: true,
315        };
316        let json = serde_json::to_string(&caps).unwrap();
317        let back: Capabilities = serde_json::from_str(&json).unwrap();
318        assert_eq!(back, caps);
319    }
320
321    #[test]
322    fn a_provider_still_declaring_the_removed_capabilities_handshakes_successfully() {
323        // ADR 0004 removes `upsert`, `subscribe`, and `filters` from the Rust
324        // API but claims the *wire* stays compatible: an already-deployed
325        // provider that still emits them is not rejected, the fields are just
326        // ignored. That claim is load-bearing for the two live downstreams, so
327        // it gets a test rather than a sentence.
328        let legacy = r#"{
329            "query": { "kinds": ["doc"], "filters": ["language"] },
330            "upsert": true,
331            "graph": false,
332            "subscribe": true
333        }"#;
334        let caps: Capabilities = serde_json::from_str(legacy).expect("legacy ack must still parse");
335        assert_eq!(caps.query.kinds, vec!["doc".to_string()]);
336        assert!(!caps.graph);
337    }
338
339    #[test]
340    fn a_well_formed_fingerprint_yields_its_dimension() {
341        assert_eq!(
342            fingerprint_dimensions("bge-small-en-v1.5/384/l2"),
343            Some(384)
344        );
345        assert_eq!(
346            fingerprint_dimensions("text-embedding-3-large/3072"),
347            Some(3072)
348        );
349    }
350
351    #[test]
352    fn a_fingerprint_without_a_parseable_dimension_yields_none() {
353        // Rather than defaulting to some guess: a host that cannot read the
354        // dimension must not pretend it validated the vector length.
355        assert_eq!(fingerprint_dimensions("bge-small-v1"), None);
356        assert_eq!(fingerprint_dimensions("model/not-a-number"), None);
357        assert_eq!(fingerprint_dimensions(""), None);
358    }
359
360    #[test]
361    fn fingerprints_match_only_on_exact_equality() {
362        let provider = "bge-small-en-v1.5/384/l2";
363        assert!(embedding_fingerprints_match(provider, provider));
364
365        // Same model and dimension, different normalization: NOT a match, and
366        // this is the case worth pinning — the vectors are the same length, so
367        // nothing downstream would notice the mismatch on its own.
368        assert!(!embedding_fingerprints_match(
369            "bge-small-en-v1.5/384",
370            provider
371        ));
372        assert!(!embedding_fingerprints_match(
373            "bge-small-en-v1.5/384/none",
374            provider
375        ));
376        assert!(!embedding_fingerprints_match(
377            "text-embedding-3-small/384/l2",
378            provider
379        ));
380    }
381
382    #[test]
383    fn an_empty_fingerprint_never_matches_even_itself() {
384        // Otherwise two providers that both declined to declare a fingerprint
385        // would appear to agree on an embedding space.
386        assert!(!embedding_fingerprints_match("", ""));
387    }
388
389    #[test]
390    fn representation_capability_defaults_to_full_only_and_is_wire_omitted() {
391        // A provider that says nothing offers `full` only, and neither new
392        // field disturbs the pre-representation wire form (resolve defaults
393        // false and is a plain bool, representations omits when empty).
394        let caps = Capabilities::default();
395        assert_eq!(caps.offered_representations(), vec![Representation::Full]);
396        assert!(caps.representations_consistent());
397        let json = serde_json::to_string(&caps).unwrap();
398        assert!(!json.contains("representations"));
399
400        // Absent from the wire ⇒ still full-only, so pre-representation
401        // providers keep working.
402        let back: Capabilities = serde_json::from_str(r#"{"upsert":false}"#).unwrap();
403        assert_eq!(back.offered_representations(), vec![Representation::Full]);
404        assert!(!back.resolve);
405    }
406
407    #[test]
408    fn reference_or_compact_without_resolve_is_inconsistent() {
409        // Compact/reference hand the host a content_ref to rehydrate; a
410        // provider that cannot resolve must not advertise them.
411        let lying = Capabilities {
412            representations: vec![Representation::Reference],
413            resolve: false,
414            ..Capabilities::default()
415        };
416        assert!(!lying.representations_consistent());
417
418        let honest = Capabilities {
419            representations: vec![Representation::Reference],
420            resolve: true,
421            ..Capabilities::default()
422        };
423        assert!(honest.representations_consistent());
424
425        // Advertising only `full` never requires resolve.
426        let full_only = Capabilities {
427            representations: vec![Representation::Full],
428            resolve: false,
429            ..Capabilities::default()
430        };
431        assert!(full_only.representations_consistent());
432    }
433}