Skip to main content

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}