contextgraph_host/provider.rs
1//! The uniform provider handle.
2//!
3//! Every context source — an in-process built-in, a child process over
4//! stdio, or a remote HTTP endpoint — reaches the host through one trait,
5//! [`ContextProvider`]. Its shape mirrors the two Context Graph Protocol methods a host always
6//! needs: capability negotiation (cached from the handshake, SPEC.md §3) and
7//! `context/query` (SPEC.md §5). `stella-context` and any other Rust agent drive
8//! all three provider kinds through this single interface
9//! (`SPEC.md` §1 — "usable by any other Rust agent that wants Context Graph Protocol
10//! support").
11
12use async_trait::async_trait;
13use contextgraph_types::{
14 Capabilities, ContextQuery, ContextQueryResult, FrameKind, ProviderInfo, Verdict,
15 VerifyRequest, VerifyResponse,
16};
17
18use crate::error::HostError;
19
20/// A registered Context Graph Protocol provider, queryable behind one handle regardless of
21/// transport. `info()`/`capabilities()` return values cached at handshake
22/// time, so they are cheap synchronous getters even for out-of-process
23/// providers.
24#[async_trait]
25pub trait ContextProvider: Send + Sync {
26 /// The provider's host-facing id — its routing key and its consent key
27 /// (`SPEC.md` §4 and §10).
28 fn id(&self) -> &str;
29
30 /// Identity + declared data-flow direction, surfaced at consent time
31 /// (SPEC.md §3, ).
32 fn info(&self) -> &ProviderInfo;
33
34 /// Capabilities negotiated at the handshake (`SPEC.md` §3): which frame
35 /// kinds this provider serves, whether it echoes a correlation `id`, does
36 /// graph, names an embedding space, answers `context/verify`, which frame
37 /// representations it can return, and whether it answers `context/resolve`.
38 ///
39 /// That is the whole of [`Capabilities`] — seven fields. This comment used
40 /// to describe `upsert`, `subscriptions` and `filters`, which
41 /// [ADR 0004](https://github.com/macanderson/context-graph-protocol/blob/main/docs/adr/0004-dead-capability-surface.md)
42 /// removed because nothing implemented them. The sentence outlived them and
43 /// was copied into `docs/implementing-a-provider.md`, so a provider author
44 /// read it as the contract (#151).
45 fn capabilities(&self) -> &Capabilities;
46
47 /// Answer a context query with budgeted, provenance-carrying frames
48 /// (SPEC.md §5). The host — not the provider — enforces the budget and consent;
49 /// a provider that over-runs its budget is caught by the host, not
50 /// trusted (`crate::host`).
51 ///
52 /// # Signing
53 ///
54 /// A provider that signs what it serves populates
55 /// [`frame_attestations`](ContextQueryResult::frame_attestations) and
56 /// [`result_attestation`](ContextQueryResult::result_attestation) on the
57 /// result it returns (`SPEC.md` §6.5.5). There is no second method and no
58 /// second channel: the evidence is part of the answer, so an in-process
59 /// provider and a transport-backed one carry it identically, and a host
60 /// can never be handed signatures that disagree with the frames they cover
61 /// (ADR 0014).
62 ///
63 /// This trait previously offered a defaulted `query_attested` returning an
64 /// `AttestedQueryResult`, from the months when the `frames` envelope had
65 /// nowhere to put an attestation. It does now, so both are gone.
66 ///
67 /// The host checks whatever arrives against its
68 /// [`TrustStore`](crate::TrustStore) and records the outcome in the
69 /// composition audit. Whatever it finds, **the frames are served either
70 /// way** (`SPEC.md` F9).
71 async fn query(&self, query: &ContextQuery) -> Result<ContextQueryResult, HostError>;
72
73 /// Revalidate frames the host already holds, without any frame body
74 /// travelling (`docs/context-reuse.md` §4 `context/verify`).
75 ///
76 /// Defaults to answering [`Verdict::Unknown`](contextgraph_types::Verdict::Unknown)
77 /// for every requested identity, so an existing provider implements
78 /// nothing and is simply treated as unable to vouch for its frames — the
79 /// host then re-queries them. A provider that overrides this **MUST** also
80 /// advertise [`Capabilities::verify`](contextgraph_types::Capabilities::verify),
81 /// since the host only asks providers that declare support.
82 async fn verify(&self, request: &VerifyRequest) -> Result<VerifyResponse, HostError> {
83 Ok(VerifyResponse::uniform(request, Verdict::Unknown))
84 }
85
86 /// Shut the provider down cleanly (SPEC.md §3 lifecycle). In-process providers
87 /// default to a no-op; transport-backed providers send `shutdown` and
88 /// reap their child. Overridable.
89 async fn shutdown(&self) -> Result<(), HostError> {
90 Ok(())
91 }
92}
93
94/// The snake_case wire name of a [`FrameKind`], matching its `serde`
95/// representation and the strings a provider lists in
96/// [`Capabilities::query`]'s `kinds`.
97///
98/// A thin delegation to [`FrameKind::as_str`], kept because several call sites
99/// read better as a function. It used to be a hand-written `match` returning
100/// `&'static str`; that duplicated the vocabulary in a second place and, worse,
101/// could not name a kind the host did not know. Now that the vocabulary is open
102/// the borrow is tied to the kind, because an unknown kind owns its string.
103pub fn frame_kind_name(kind: &FrameKind) -> &str {
104 kind.as_str()
105}
106
107/// Whether a provider is worth querying for a given request. A query with no
108/// `kinds` filter matches every provider (the host wants everyone's best
109/// frames, SPEC.md §5); otherwise a provider matches when it declares at least one
110/// of the requested frame kinds. Used by `query_all` to fan out only to
111/// relevant providers.
112pub fn capability_matches(caps: &Capabilities, query: &ContextQuery) -> bool {
113 if query.kinds.is_empty() {
114 return true;
115 }
116 query.kinds.iter().any(|requested| {
117 let name = frame_kind_name(requested);
118 caps.query.kinds.iter().any(|served| served == name)
119 })
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125 use contextgraph_types::capability::QueryCapability;
126
127 fn caps_for(kinds: &[&str]) -> Capabilities {
128 Capabilities {
129 query: QueryCapability {
130 kinds: kinds.iter().map(|k| k.to_string()).collect(),
131 },
132 ..Capabilities::default()
133 }
134 }
135
136 fn query_for(kinds: Vec<FrameKind>) -> ContextQuery {
137 ContextQuery {
138 goal: "g".into(),
139 query_text: None,
140 embedding: None,
141 kinds,
142 anchors: vec![],
143 max_frames: 5,
144 max_tokens: 1000,
145 as_of: None,
146 representation_preferences: vec![],
147 }
148 }
149
150 #[test]
151 fn frame_kind_names_match_serde_snake_case() {
152 // The names a provider lists must be exactly the frames' serde names.
153 for (kind, name) in [
154 (FrameKind::Snippet, "snippet"),
155 (FrameKind::Symbol, "symbol"),
156 (FrameKind::Fact, "fact"),
157 (FrameKind::Doc, "doc"),
158 (FrameKind::Memory, "memory"),
159 (FrameKind::Episode, "episode"),
160 (FrameKind::Graph, "graph"),
161 ] {
162 assert_eq!(frame_kind_name(&kind), name);
163 let serde_name = serde_json::to_value(&kind).unwrap();
164 assert_eq!(serde_name, serde_json::Value::String(name.to_string()));
165 }
166 }
167
168 #[test]
169 fn an_empty_kind_filter_matches_every_provider() {
170 let caps = caps_for(&["doc"]);
171 assert!(capability_matches(&caps, &query_for(vec![])));
172 }
173
174 #[test]
175 fn a_kind_filter_matches_only_overlapping_providers() {
176 let doc_provider = caps_for(&["doc", "snippet"]);
177 assert!(capability_matches(
178 &doc_provider,
179 &query_for(vec![FrameKind::Doc])
180 ));
181 assert!(capability_matches(
182 &doc_provider,
183 &query_for(vec![FrameKind::Fact, FrameKind::Snippet])
184 ));
185 assert!(!capability_matches(
186 &doc_provider,
187 &query_for(vec![FrameKind::Memory, FrameKind::Episode])
188 ));
189 }
190}