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}