contextgraph_host/wire.rs
1//! The CGP wire envelope and its framing (`SPEC.md` §Transport bindings).
2//!
3//! # This is not JSON-RPC
4//!
5//! Earlier revisions of this module claimed CGP "rides MCP's transport and
6//! lifecycle conventions (JSON-RPC 2.0, ...)". That was not true of the wire it
7//! described, and the claim has been withdrawn — see
8//! [ADR 0002](../../docs/adr/0002-request-correlation-and-the-json-rpc-question.md).
9//! CGP's envelope is a bespoke `type`-tagged JSON object: there is no
10//! `jsonrpc` member, no `method`/`params` split. Its *lifecycle* is informed by
11//! MCP (a handshake that negotiates version and capabilities before any
12//! payload moves), but the framing is its own.
13//!
14//! A JSON-RPC **binding** — an alternate encoding of this same semantic layer —
15//! may be specified later without touching frame or query semantics and without
16//! a new protocol family. Keeping the semantic layer and the transport binding
17//! separate is what makes that possible.
18//!
19//! # Framing
20//!
21//! Every message is **newline-delimited JSON (NDJSON): exactly one
22//! `serde_json` value per line** — the simplest thing that is unambiguous over
23//! a pipe and trivially reimplementable in a provider kit in any language. HTTP
24//! providers receive the same envelope as a JSON request body and reply with
25//! one as the response body.
26//!
27//! The envelope is **versioned**: the handshake negotiates the protocol family
28//! up front, and a mismatch is a named error, never a hang (`SPEC.md` §H3).
29//!
30//! # Correlation
31//!
32//! `query`, `frames`, and `error` carry an optional [`id`](Envelope). A
33//! provider **MUST** echo the `id` of the request it is answering. An envelope
34//! with no `id` is a *notification*: it expects no reply, which is the shape a
35//! future push-invalidation extension needs
36//! (`docs/sketches/push-invalidation.md`).
37//!
38//! `id` is optional so that a provider written against an earlier revision
39//! stays conformant: it is queried in lock-step and is fully conformant.
40//!
41//! Correlation is negotiated **explicitly**, via
42//! [`Capabilities::correlation`](contextgraph_types::Capabilities::correlation)
43//! — a host **MUST NOT** send an `id` to a provider that did not declare it
44//! (`SPEC.md` §3.2). This paragraph previously said the opposite ("negotiated
45//! by observation, not by a capability flag"), which predates the capability
46//! and inverts a MUST NOT: a reply carrying no `id` is ambiguous between "does
47//! not implement correlation" and "implements it incorrectly", and a guarantee
48//! whose violation is indistinguishable from legitimate behaviour cannot be
49//! checked.
50//!
51//! Note that `verify`/`verified` carry no `id` at all: a verdict echoes the
52//! frame identity it answers *in full*, so those exchanges correlate by
53//! matching rather than by envelope id (`SPEC.md` §9).
54
55use contextgraph_types::{
56 Capabilities, ContextQuery, ContextQueryResult, ErrorCode, ProviderInfo, VerifyRequest,
57 VerifyResponse,
58};
59use serde::{Deserialize, Serialize};
60
61use crate::error::HostError;
62
63/// One Context Graph Protocol message. Every variant is a small, versioned, `type`-tagged JSON
64/// object; the host writes exactly one per line (NDJSON) over stdio and one
65/// per HTTP body (`SPEC.md` §2).
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67#[serde(tag = "type", rename_all = "snake_case")]
68pub enum Envelope {
69 /// Host hello: opens the exchange with the protocol version the host
70 /// speaks (SPEC.md §3 `initialize`).
71 Handshake { protocol_version: String },
72 /// Provider hello-back: its protocol version, identity + declared
73 /// data-flow direction, and negotiated capabilities (SPEC.md §3). The host
74 /// checks the version and surfaces `provider.data_flow` at consent time.
75 HandshakeAck {
76 protocol_version: String,
77 provider: ProviderInfo,
78 capabilities: Capabilities,
79 },
80 /// Host → provider retrieval request (`context/query`).
81 Query {
82 /// Correlation id. A provider **MUST** echo it on the reply.
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 id: Option<String>,
85 query: ContextQuery,
86 },
87 /// Provider → host budgeted, provenance-carrying frames.
88 Frames {
89 /// The `id` of the `query` this answers, echoed verbatim.
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 id: Option<String>,
92 result: ContextQueryResult,
93 },
94 /// Host → provider revalidation request: are these held frames still
95 /// valid (`docs/context-reuse.md` §4 `context/verify`)? Carries frame
96 /// identities only — never bodies. Capability-gated: a host sends it only
97 /// to a provider advertising [`Capabilities::verify`](contextgraph_types::Capabilities::verify).
98 Verify { request: VerifyRequest },
99 /// Provider → host per-frame verdicts.
100 Verified { response: VerifyResponse },
101 /// Lifecycle teardown; the provider should exit cleanly.
102 Shutdown,
103 /// Provider-reported failure — lets a provider report a bad request
104 /// without dying (`SPEC.md` §R1 "fail loud"). The host maps this to
105 /// [`HostError::Provider`].
106 Error {
107 /// The `id` of the request that failed, when the failure is
108 /// attributable to one.
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 id: Option<String>,
111 /// Machine-readable classification (`SPEC.md` §Errors). Optional so
112 /// that a provider written against an earlier revision stays
113 /// conformant; a host treats its absence as
114 /// [`ErrorCode::Internal`](contextgraph_types::ErrorCode::Internal).
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 code: Option<ErrorCode>,
117 /// Human-readable detail. Always present: the code is for the machine,
118 /// the message is for whoever reads the log.
119 message: String,
120 },
121}
122
123impl Envelope {
124 /// The correlation id this envelope carries, if any.
125 ///
126 /// `None` means one of two things, and the caller can tell them apart from
127 /// context: either the envelope is a lifecycle message that never carries
128 /// one (`handshake`, `shutdown`), or the peer does not implement
129 /// correlation and the exchange must stay lock-step.
130 pub fn correlation_id(&self) -> Option<&str> {
131 match self {
132 Envelope::Query { id, .. }
133 | Envelope::Frames { id, .. }
134 | Envelope::Error { id, .. } => id.as_deref(),
135 _ => None,
136 }
137 }
138
139 /// The error code carried by an `error` envelope, defaulting to
140 /// [`ErrorCode::Internal`] when the provider declared none.
141 ///
142 /// Defaulting to `Internal` rather than to something retryable is the
143 /// conservative reading: a host must not infer "safe to retry" from a
144 /// provider's silence.
145 pub fn error_code(&self) -> Option<ErrorCode> {
146 match self {
147 Envelope::Error { code, .. } => Some(code.clone().unwrap_or(ErrorCode::Internal)),
148 _ => None,
149 }
150 }
151}
152
153/// Mint a fresh correlation id.
154///
155/// Ids need only be unique among the exchanges in flight on one connection; a
156/// process-wide counter satisfies that with no per-connection state and no
157/// randomness dependency. They are opaque to the provider, which must echo the
158/// string verbatim rather than parse it.
159pub fn next_correlation_id() -> String {
160 use std::sync::atomic::{AtomicU64, Ordering};
161 static COUNTER: AtomicU64 = AtomicU64::new(1);
162 format!("q{}", COUNTER.fetch_add(1, Ordering::Relaxed))
163}
164
165/// Check that a reply carries the correlation id of the request it answers
166/// (`SPEC.md` §H4).
167///
168/// `sent` is `None` when the provider did not declare `correlation`, in which
169/// case the exchange is lock-step and there is nothing to verify.
170pub fn verify_correlation(
171 provider_id: &str,
172 sent: Option<&str>,
173 echoed: Option<&str>,
174) -> Result<(), HostError> {
175 let Some(expected) = sent else {
176 return Ok(());
177 };
178 match echoed {
179 Some(got) if got == expected => Ok(()),
180 // A correlation-declaring provider that answers without an id, or with
181 // the wrong one, is worse than one that never declared it: a host that
182 // accepted the reply anyway could hand one caller's frames to another.
183 other => Err(HostError::CorrelationMismatch {
184 id: provider_id.to_string(),
185 expected: expected.to_string(),
186 got: other.unwrap_or("<absent>").to_string(),
187 }),
188 }
189}
190
191/// The human name of an envelope variant, for error messages that report
192/// "expected X, got Y".
193pub fn envelope_kind(env: &Envelope) -> &'static str {
194 match env {
195 Envelope::Handshake { .. } => "handshake",
196 Envelope::HandshakeAck { .. } => "handshake_ack",
197 Envelope::Query { .. } => "query",
198 Envelope::Frames { .. } => "frames",
199 Envelope::Verify { .. } => "verify",
200 Envelope::Verified { .. } => "verified",
201 Envelope::Shutdown => "shutdown",
202 Envelope::Error { .. } => "error",
203 }
204}
205
206/// Serialize an envelope to a single NDJSON line (trailing `\n` included).
207pub fn encode_line(env: &Envelope) -> Result<String, HostError> {
208 let mut line = serde_json::to_string(env).map_err(|e| HostError::Wire(e.to_string()))?;
209 line.push('\n');
210 Ok(line)
211}
212
213/// Parse one NDJSON line into an envelope. A garbage line is a clean
214/// [`HostError::Wire`], never a panic — the crash-consistency contract
215/// (task deliverable 5).
216pub fn decode_line(line: &str) -> Result<Envelope, HostError> {
217 serde_json::from_str(line.trim_end()).map_err(|e| HostError::Wire(e.to_string()))
218}
219
220/// Two protocol version strings interoperate when they share a **major
221/// family** — the substring up to the first `.`. So `contextgraph/1.0-draft` and
222/// `contextgraph/1.0` interoperate (both `contextgraph/1`), while `contextgraph/2.0` does not. This is
223/// what lets the public v1.0 freeze drop the `-draft` suffix without a flag
224/// day (`SPEC.md`).
225pub fn versions_compatible(a: &str, b: &str) -> bool {
226 protocol_family(a) == protocol_family(b)
227}
228
229fn protocol_family(version: &str) -> &str {
230 match version.split_once('.') {
231 Some((family, _)) => family,
232 None => version,
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use contextgraph_types::capability::QueryCapability;
240 use contextgraph_types::{DataFlow, FrameKind, PROTOCOL_VERSION};
241
242 fn sample_ack() -> Envelope {
243 Envelope::HandshakeAck {
244 protocol_version: PROTOCOL_VERSION.to_string(),
245 provider: ProviderInfo {
246 name: "contextgraph-docs".into(),
247 version: "0.1.0".into(),
248 data_flow: DataFlow {
249 reads: true,
250 writes: false,
251 egress: false,
252 egress_scopes: vec![],
253 },
254 },
255 capabilities: Capabilities {
256 query: QueryCapability {
257 kinds: vec!["doc".into()],
258 },
259 ..Capabilities::default()
260 },
261 }
262 }
263
264 #[test]
265 fn envelope_kind_matches_the_serialized_type_tag_for_every_variant() {
266 // `envelope_kind` hand-writes the strings serde derives from
267 // `rename_all = "snake_case"`; they feed "expected X, got Y" wire
268 // errors. Without this test, renaming a variant would silently
269 // desynchronize the error text from the actual wire tag.
270 let variants: Vec<Envelope> = vec![
271 Envelope::Handshake {
272 protocol_version: PROTOCOL_VERSION.to_string(),
273 },
274 sample_ack(),
275 Envelope::Query {
276 id: None,
277 query: ContextQuery {
278 goal: "g".into(),
279 query_text: None,
280 embedding: None,
281 kinds: vec![],
282 anchors: vec![],
283 max_frames: 1,
284 max_tokens: 1,
285 as_of: None,
286 representation_preferences: vec![],
287 },
288 },
289 Envelope::Frames {
290 id: None,
291 result: ContextQueryResult {
292 frames: vec![],
293 truncated: false,
294 dropped_estimate: None,
295 },
296 },
297 Envelope::Shutdown,
298 Envelope::Error {
299 id: None,
300 code: None,
301 message: "m".into(),
302 },
303 ];
304 for env in &variants {
305 let value: serde_json::Value =
306 serde_json::from_str(encode_line(env).unwrap().trim_end()).unwrap();
307 assert_eq!(
308 value["type"].as_str(),
309 Some(envelope_kind(env)),
310 "envelope_kind drifted from the serde tag for {env:?}"
311 );
312 }
313 }
314
315 #[test]
316 fn envelope_roundtrips_through_a_single_ndjson_line() {
317 let env = sample_ack();
318 let line = encode_line(&env).unwrap();
319 assert!(line.ends_with('\n'), "a frame is exactly one line");
320 assert_eq!(line.matches('\n').count(), 1, "no embedded newlines");
321 let back = decode_line(&line).unwrap();
322 assert_eq!(back, env);
323 }
324
325 #[test]
326 fn query_envelope_carries_contextgraph_types_shapes_verbatim() {
327 let query = ContextQuery {
328 goal: "fix the failing test".into(),
329 query_text: None,
330 embedding: None,
331 kinds: vec![FrameKind::Doc],
332 anchors: vec![],
333 max_frames: 5,
334 max_tokens: 2000,
335 as_of: None,
336 representation_preferences: vec![],
337 };
338 let env = Envelope::Query {
339 id: None,
340 query: query.clone(),
341 };
342 let line = encode_line(&env).unwrap();
343 match decode_line(&line).unwrap() {
344 Envelope::Query { query: back, .. } => assert_eq!(back, query),
345 other => panic!("expected query, got {}", envelope_kind(&other)),
346 }
347 }
348
349 #[test]
350 fn a_garbage_line_is_a_clean_wire_error_never_a_panic() {
351 let err = decode_line("this is not json {{{").unwrap_err();
352 assert!(matches!(err, HostError::Wire(_)));
353 }
354
355 #[test]
356 fn version_families_interoperate_within_a_major_but_not_across() {
357 assert!(versions_compatible(
358 "contextgraph/1.0-draft",
359 "contextgraph/1.0"
360 ));
361 assert!(versions_compatible(
362 "contextgraph/1.0-draft",
363 "contextgraph/1.0-draft"
364 ));
365 assert!(versions_compatible(PROTOCOL_VERSION, "contextgraph/1.9"));
366 assert!(!versions_compatible(
367 "contextgraph/1.0-draft",
368 "contextgraph/2.0"
369 ));
370 assert!(!versions_compatible("contextgraph/1.0", "mcp/1.0"));
371 }
372}