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/// A public key a provider publishes at the handshake, so a verifier can check
64/// the [attestations](contextgraph_types::FrameAttestation) it goes on to
65/// serve (`SPEC.md` §6.5.4).
66///
67/// **A construction anchor, not a trust anchor.** A key handed over by the party
68/// being audited says nothing about *who* signed; it is enough to decide whether
69/// an attestation is built the way §6.5 requires, which is the half F6–F9 make
70/// mandatory. A deployment that cares who signed resolves
71/// [`key_id`](Self::key_id) in its own trust store and ignores this field.
72///
73/// It rides the **handshake** rather than the answer for a reason: a key
74/// republished with every response could be swapped by the same forgery that
75/// swapped the signature, and a wrong-key signature would then verify. Declared
76/// once, before any frame moves, it cannot be.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct AttesterKey {
79 /// The key's id, matching [`ProvenanceAttestation::key_id`](contextgraph_types::ProvenanceAttestation::key_id). Rotation is a
80 /// new id, never a reused one.
81 pub key_id: String,
82 /// The scheme this key is for, e.g.
83 /// [`ALGORITHM_ED25519`](contextgraph_types::ALGORITHM_ED25519).
84 pub algorithm: String,
85 /// The raw public key, lowercase hex — the encoding
86 /// [`ProvenanceAttestation::signature`](contextgraph_types::ProvenanceAttestation::signature) already uses.
87 pub public_key: String,
88}
89
90/// One Context Graph Protocol message. Every variant is a small, versioned, `type`-tagged JSON
91/// object; the host writes exactly one per line (NDJSON) over stdio and one
92/// per HTTP body (`SPEC.md` §2).
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94#[serde(tag = "type", rename_all = "snake_case")]
95pub enum Envelope {
96 /// Host hello: opens the exchange with the protocol version the host
97 /// speaks (SPEC.md §3 `initialize`).
98 Handshake { protocol_version: String },
99 /// Provider hello-back: its protocol version, identity + declared
100 /// data-flow direction, and negotiated capabilities (SPEC.md §3). The host
101 /// checks the version and surfaces `provider.data_flow` at consent time.
102 HandshakeAck {
103 protocol_version: String,
104 provider: ProviderInfo,
105 capabilities: Capabilities,
106 /// The public keys this provider signs its attestations with
107 /// (`SPEC.md` §6.5). Empty ⇒ the provider offers no attestation, which
108 /// is conformant: §6.5 makes the *construction* mandatory, never the
109 /// signing.
110 #[serde(default, skip_serializing_if = "Vec::is_empty")]
111 attester_keys: Vec<AttesterKey>,
112 },
113 /// Host → provider retrieval request (`context/query`).
114 Query {
115 /// Correlation id. A provider **MUST** echo it on the reply.
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 id: Option<String>,
118 query: ContextQuery,
119 },
120 /// Provider → host budgeted, provenance-carrying frames.
121 Frames {
122 /// The `id` of the `query` this answers, echoed verbatim.
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 id: Option<String>,
125 /// The answer, including any detached provenance attestations it
126 /// carries. `SPEC.md` §6.5.5 puts those on the *result* and nowhere
127 /// else: an in-process provider that never builds an `Envelope` must
128 /// still be able to sign what it serves, and one wire home means two
129 /// encodings of the same signature can never disagree (ADR 0014).
130 result: ContextQueryResult,
131 },
132 /// Host → provider revalidation request: are these held frames still
133 /// valid (`docs/context-reuse.md` §4 `context/verify`)? Carries frame
134 /// identities only — never bodies. Capability-gated: a host sends it only
135 /// to a provider advertising [`Capabilities::verify`](contextgraph_types::Capabilities::verify).
136 Verify { request: VerifyRequest },
137 /// Provider → host per-frame verdicts.
138 Verified { response: VerifyResponse },
139 /// Lifecycle teardown; the provider should exit cleanly.
140 Shutdown,
141 /// Provider-reported failure — lets a provider report a bad request
142 /// without dying (`SPEC.md` §R1 "fail loud"). The host maps this to
143 /// [`HostError::Provider`].
144 Error {
145 /// The `id` of the request that failed, when the failure is
146 /// attributable to one.
147 #[serde(default, skip_serializing_if = "Option::is_none")]
148 id: Option<String>,
149 /// Machine-readable classification (`SPEC.md` §Errors). Optional so
150 /// that a provider written against an earlier revision stays
151 /// conformant; a host treats its absence as
152 /// [`ErrorCode::Internal`](contextgraph_types::ErrorCode::Internal).
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 code: Option<ErrorCode>,
155 /// Human-readable detail. Always present: the code is for the machine,
156 /// the message is for whoever reads the log.
157 message: String,
158 },
159}
160
161impl Envelope {
162 /// The correlation id this envelope carries, if any.
163 ///
164 /// `None` means one of two things, and the caller can tell them apart from
165 /// context: either the envelope is a lifecycle message that never carries
166 /// one (`handshake`, `shutdown`), or the peer does not implement
167 /// correlation and the exchange must stay lock-step.
168 pub fn correlation_id(&self) -> Option<&str> {
169 match self {
170 Envelope::Query { id, .. }
171 | Envelope::Frames { id, .. }
172 | Envelope::Error { id, .. } => id.as_deref(),
173 _ => None,
174 }
175 }
176
177 /// The error code carried by an `error` envelope, defaulting to
178 /// [`ErrorCode::Internal`] when the provider declared none.
179 ///
180 /// Defaulting to `Internal` rather than to something retryable is the
181 /// conservative reading: a host must not infer "safe to retry" from a
182 /// provider's silence.
183 pub fn error_code(&self) -> Option<ErrorCode> {
184 match self {
185 Envelope::Error { code, .. } => Some(code.clone().unwrap_or(ErrorCode::Internal)),
186 _ => None,
187 }
188 }
189}
190
191/// Mint a fresh correlation id.
192///
193/// Ids need only be unique among the exchanges in flight on one connection; a
194/// process-wide counter satisfies that with no per-connection state and no
195/// randomness dependency. They are opaque to the provider, which must echo the
196/// string verbatim rather than parse it.
197pub fn next_correlation_id() -> String {
198 use std::sync::atomic::{AtomicU64, Ordering};
199 static COUNTER: AtomicU64 = AtomicU64::new(1);
200 format!("q{}", COUNTER.fetch_add(1, Ordering::Relaxed))
201}
202
203/// Check that a reply carries the correlation id of the request it answers
204/// (`SPEC.md` §H4).
205///
206/// `sent` is `None` when the provider did not declare `correlation`, in which
207/// case the exchange is lock-step and there is nothing to verify.
208pub fn verify_correlation(
209 provider_id: &str,
210 sent: Option<&str>,
211 echoed: Option<&str>,
212) -> Result<(), HostError> {
213 let Some(expected) = sent else {
214 return Ok(());
215 };
216 match echoed {
217 Some(got) if got == expected => Ok(()),
218 // A correlation-declaring provider that answers without an id, or with
219 // the wrong one, is worse than one that never declared it: a host that
220 // accepted the reply anyway could hand one caller's frames to another.
221 other => Err(HostError::CorrelationMismatch {
222 id: provider_id.to_string(),
223 expected: expected.to_string(),
224 got: other.unwrap_or("<absent>").to_string(),
225 }),
226 }
227}
228
229/// The human name of an envelope variant, for error messages that report
230/// "expected X, got Y".
231pub fn envelope_kind(env: &Envelope) -> &'static str {
232 match env {
233 Envelope::Handshake { .. } => "handshake",
234 Envelope::HandshakeAck { .. } => "handshake_ack",
235 Envelope::Query { .. } => "query",
236 Envelope::Frames { .. } => "frames",
237 Envelope::Verify { .. } => "verify",
238 Envelope::Verified { .. } => "verified",
239 Envelope::Shutdown => "shutdown",
240 Envelope::Error { .. } => "error",
241 }
242}
243
244/// Serialize an envelope to a single NDJSON line (trailing `\n` included).
245pub fn encode_line(env: &Envelope) -> Result<String, HostError> {
246 let mut line = serde_json::to_string(env).map_err(|e| HostError::Wire(e.to_string()))?;
247 line.push('\n');
248 Ok(line)
249}
250
251/// Parse one NDJSON line into an envelope. A garbage line is a clean
252/// [`HostError::Wire`], never a panic — the crash-consistency contract
253/// (task deliverable 5).
254pub fn decode_line(line: &str) -> Result<Envelope, HostError> {
255 serde_json::from_str(line.trim_end()).map_err(|e| HostError::Wire(e.to_string()))
256}
257
258/// Two protocol version strings interoperate when they share a **major
259/// family** — the substring up to the first `.`. So `contextgraph/1.0-draft` and
260/// `contextgraph/1.0` interoperate (both `contextgraph/1`), while `contextgraph/2.0` does not. This is
261/// what allowed the public v1.0 freeze to drop the `-draft` suffix without a flag
262/// day (`SPEC.md`).
263pub fn versions_compatible(a: &str, b: &str) -> bool {
264 protocol_family(a) == protocol_family(b)
265}
266
267fn protocol_family(version: &str) -> &str {
268 match version.split_once('.') {
269 Some((family, _)) => family,
270 None => version,
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use contextgraph_types::capability::QueryCapability;
278 use contextgraph_types::{DataFlow, FrameKind, PROTOCOL_VERSION};
279
280 fn sample_ack() -> Envelope {
281 Envelope::HandshakeAck {
282 protocol_version: PROTOCOL_VERSION.to_string(),
283 provider: ProviderInfo {
284 name: "contextgraph-docs".into(),
285 version: "0.1.0".into(),
286 data_flow: DataFlow {
287 reads: true,
288 writes: false,
289 egress: false,
290 egress_scopes: vec![],
291 },
292 },
293 capabilities: Capabilities {
294 query: QueryCapability {
295 kinds: vec!["doc".into()],
296 },
297 ..Capabilities::default()
298 },
299 attester_keys: vec![],
300 }
301 }
302
303 #[test]
304 fn envelope_kind_matches_the_serialized_type_tag_for_every_variant() {
305 // `envelope_kind` hand-writes the strings serde derives from
306 // `rename_all = "snake_case"`; they feed "expected X, got Y" wire
307 // errors. Without this test, renaming a variant would silently
308 // desynchronize the error text from the actual wire tag.
309 let variants: Vec<Envelope> = vec![
310 Envelope::Handshake {
311 protocol_version: PROTOCOL_VERSION.to_string(),
312 },
313 sample_ack(),
314 Envelope::Query {
315 id: None,
316 query: ContextQuery {
317 goal: "g".into(),
318 query_text: None,
319 embedding: None,
320 kinds: vec![],
321 anchors: vec![],
322 max_frames: 1,
323 max_tokens: 1,
324 as_of: None,
325 representation_preferences: vec![],
326 },
327 },
328 Envelope::Frames {
329 id: None,
330 result: ContextQueryResult::unattested(vec![], false, None),
331 },
332 Envelope::Shutdown,
333 Envelope::Error {
334 id: None,
335 code: None,
336 message: "m".into(),
337 },
338 ];
339 for env in &variants {
340 let value: serde_json::Value =
341 serde_json::from_str(encode_line(env).unwrap().trim_end()).unwrap();
342 assert_eq!(
343 value["type"].as_str(),
344 Some(envelope_kind(env)),
345 "envelope_kind drifted from the serde tag for {env:?}"
346 );
347 }
348 }
349
350 #[test]
351 fn envelope_roundtrips_through_a_single_ndjson_line() {
352 let env = sample_ack();
353 let line = encode_line(&env).unwrap();
354 assert!(line.ends_with('\n'), "a frame is exactly one line");
355 assert_eq!(line.matches('\n').count(), 1, "no embedded newlines");
356 let back = decode_line(&line).unwrap();
357 assert_eq!(back, env);
358 }
359
360 #[test]
361 fn query_envelope_carries_contextgraph_types_shapes_verbatim() {
362 let query = ContextQuery {
363 goal: "fix the failing test".into(),
364 query_text: None,
365 embedding: None,
366 kinds: vec![FrameKind::Doc],
367 anchors: vec![],
368 max_frames: 5,
369 max_tokens: 2000,
370 as_of: None,
371 representation_preferences: vec![],
372 };
373 let env = Envelope::Query {
374 id: None,
375 query: query.clone(),
376 };
377 let line = encode_line(&env).unwrap();
378 match decode_line(&line).unwrap() {
379 Envelope::Query { query: back, .. } => assert_eq!(back, query),
380 other => panic!("expected query, got {}", envelope_kind(&other)),
381 }
382 }
383
384 #[test]
385 fn a_garbage_line_is_a_clean_wire_error_never_a_panic() {
386 let err = decode_line("this is not json {{{").unwrap_err();
387 assert!(matches!(err, HostError::Wire(_)));
388 }
389
390 #[test]
391 fn version_families_interoperate_within_a_major_but_not_across() {
392 assert!(versions_compatible(
393 "contextgraph/1.0-draft",
394 "contextgraph/1.0"
395 ));
396 assert!(versions_compatible(
397 "contextgraph/1.0",
398 "contextgraph/1.0-draft"
399 ));
400 assert!(versions_compatible(PROTOCOL_VERSION, "contextgraph/1.9"));
401 assert!(!versions_compatible("contextgraph/1.0", "contextgraph/2.0"));
402 assert!(!versions_compatible("contextgraph/1.0", "mcp/1.0"));
403 }
404}