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/// 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}